zaphyra's git: domsonic

subsonic web-client

commit fb059ecf83a96aa73edaa8103afd3d25a03426c2
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Fri, 15 May 2026 18:45:30 +0200

initial commit
74 files changed, 10468 insertions(+), 0 deletions(-)
A
.env
|
3
+++
A
.gitignore
|
2
++
A
.yarnrc.yml
|
1
+
A
index.html
|
16
++++++++++++++++
A
package.json
|
29
+++++++++++++++++++++++++++++
A
public/manifest.webmanifest
|
9
+++++++++
A
public/service-worker.js
|
254
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/assets/fallback.svg
|
21
+++++++++++++++++++++
A
src/audioController.ts
|
576
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/AlbumList.vine.ts
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/ArtistList.vine.ts
|
42
++++++++++++++++++++++++++++++++++++++++++
A
src/components/ConfirmDialog.vine.ts
|
49
+++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/ContextMenu.vine.ts
|
62
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/Dropdown.vine.ts
|
42
++++++++++++++++++++++++++++++++++++++++++
A
src/components/EditPlaylistModal.vine.ts
|
82
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/EmptyIndicator.vine.ts
|
13
+++++++++++++
A
src/components/Header.vine.ts
|
27
+++++++++++++++++++++++++++
A
src/components/Icon.vine.ts
|
123
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/InfiniteLoader.vine.ts
|
37
+++++++++++++++++++++++++++++++++++++
A
src/components/Logo.vine.ts
|
42
++++++++++++++++++++++++++++++++++++++++++
A
src/components/Navbars.vine.ts
|
438
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/OverflowMenu.vine.ts
|
14
++++++++++++++
A
src/components/Player.vine.ts
|
227
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/PlaylistList.vine.ts
|
64
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/SearchInput.vine.ts
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/SwitchInput.vine.ts
|
19
+++++++++++++++++++
A
src/components/Tiles.vine.ts
|
57
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/TrackList.vine.ts
|
268
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/components/index.ts
|
22
++++++++++++++++++++++
A
src/env.d.ts
|
12
++++++++++++
A
src/main.ts
|
101
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/reload.ts
|
6
++++++
A
src/router.ts
|
176
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/store/cache.ts
|
368
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/store/favourite.ts
|
49
+++++++++++++++++++++++++++++++++++++++++++++++++
A
src/store/main.ts
|
102
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/store/player.ts
|
799
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/store/playlist.ts
|
54
++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/store/radio.ts
|
295
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/discover.scss
|
22
++++++++++++++++++++++
A
src/style/dropdown.scss
|
97
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/header.scss
|
160
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/input.scss
|
192
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/loader.scss
|
30
++++++++++++++++++++++++++++++
A
src/style/login.scss
|
9
+++++++++
A
src/style/main.scss
|
67
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/modal.css
|
39
+++++++++++++++++++++++++++++++++++++++
A
src/style/navbar.scss
|
142
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/player.css
|
148
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/reset.scss
|
60
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/scroll.scss
|
32
++++++++++++++++++++++++++++++++
A
src/style/search.scss
|
25
+++++++++++++++++++++++++
A
src/style/table.scss
|
117
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/tiles.scss
|
74
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/utils.scss
|
212
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/style/variables.scss
|
37
+++++++++++++++++++++++++++++++++++++
A
src/subsonicApi.ts
|
808
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/types.ts
|
145
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/utils.ts
|
99
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/About.vine.ts
|
32
++++++++++++++++++++++++++++++++
A
src/view/Album.vine.ts
|
252
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Artist.vine.ts
|
281
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Discover.vine.ts
|
240
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Favourites.vine.ts
|
77
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Genre.vine.ts
|
158
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Login.vine.ts
|
115
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/PlayerQueue.vine.ts
|
88
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Playlist.vine.ts
|
194
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Playlists.vine.ts
|
107
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/Root.vine.ts
|
50
++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/view/SearchResult.vine.ts
|
100
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
tsconfig.json
|
43
+++++++++++++++++++++++++++++++++++++++++++
A
vite.config.mjs
|
26
++++++++++++++++++++++++++
A
yarn.lock
|
1517
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
diff --git a/.env b/.env
@@ -0,0 +1,3 @@
+VITE_APP_NAME="Domsonic"
+VITE_APP_GIT_URL="https://git.zaphyra.eu/domsonic"
+VITE_SERVER_URL="https://music.zaphyra.eu/"
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/.yarnrc.yml b/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/index.html b/index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="theme-color" content="#000" />
+    <link rel="icon" href="/icon.svg">
+    <link rel=manifest href="/manifest.webmanifest">
+    <title></title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <div id="dialogBoxes"></div>
+  </body>
+  <script type="module" src="src/main.ts"></script>
+</html>
diff --git a/package.json b/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "domsonic",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "start": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@vueuse/core": "^11.1.0",
+    "lodash-es": "^4.17.21",
+    "pinia": "^3.0.4",
+    "ts-md5": "^2.0.1",
+    "vue": "3.6.0-beta.9",
+    "vue-router": "^4.3.3",
+    "vue-tsc": "^3.2.9",
+    "vue-vine-tsc": "^1.7.27"
+  },
+  "devDependencies": {
+    "@vue/tsconfig": "^0.9.1",
+    "sass": "^1.98.0",
+    "typescript": "^6.0.2",
+    "vite": "^8.0.3",
+    "vite-plugin-checker": "^0.12.0",
+    "vue-vine": "^1.7.27"
+  }
+}
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
@@ -0,0 +1,9 @@
+{
+  "name": "Domsonic",
+  "short_name": "Domsonic",
+  "start_url": "/",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#000000",
+  "icons": [ ]
+}
diff --git a/public/service-worker.js b/public/service-worker.js
@@ -0,0 +1,254 @@
+const
+  APP_BASE = '/',
+  SW_VERSION = 'v1.2.0',
+
+  // Cache names
+  SHELL_CACHE = `shell-${SW_VERSION}`,
+  RUNTIME_CACHE = `runtime-${SW_VERSION}`,
+  IMAGE_CACHE = `images-${SW_VERSION}`,
+  AUDIO_CACHE = 'domsonic-cache-v1',
+
+  // Limits
+  MAX_IMAGE_ENTRIES = 10000,
+
+  // App shell
+  APP_SHELL = [
+    `${APP_BASE}`,
+    `${APP_BASE}index.html`,
+    `${APP_BASE}manifest.webmanifest`,
+    `${APP_BASE}icon.png`,
+  ],
+
+  // Helpers 
+  isStaticAsset = (path) => (/\.(js|css|woff2?|ttf|png|jpg|svg|webp)$/i.test(path)),
+  trimCache = async (cacheName, maxEntries) => {
+    const
+      cache = await caches.open(cacheName),
+      keys = await cache.keys()
+
+    if (keys.length <= maxEntries) return
+
+    const deleteCount = keys.length - maxEntries
+
+    for (let i = 0; i < deleteCount; i++) {
+      await cache.delete(keys[i])
+    }
+  }
+
+/* -------------------------------------------------- */
+/* INSTALL */
+/* -------------------------------------------------- */
+self.addEventListener('install', event => {
+  self.skipWaiting()
+  event.waitUntil(
+    caches.open(SHELL_CACHE)
+      .then(
+        cache => cache.addAll(APP_SHELL)
+      )
+  )
+})
+
+/* -------------------------------------------------- */
+/* ACTIVATE */
+/* -------------------------------------------------- */
+self.addEventListener(
+  'activate',
+  event => event.waitUntil(
+    (async () => {
+      const
+        keys = await caches.keys(),
+        allowed = [
+          SHELL_CACHE,
+          RUNTIME_CACHE,
+          IMAGE_CACHE,
+          AUDIO_CACHE
+        ]
+
+      await Promise.all(
+        keys
+          .filter(key => !allowed.includes(key))
+          .map(key => caches.delete(key))
+      )
+      await self.clients.claim()
+    })()
+  )
+)
+
+/* -------------------------------------------------- */
+/* FETCH */
+/* -------------------------------------------------- */
+self.addEventListener(
+  'fetch',
+  event => {
+    const
+      request = event.request,
+      url = new URL(request.url)
+
+    if (request.method !== 'GET') return
+
+    /* SPA navigation */
+    if (request.mode === 'navigate') {
+      event.respondWith(networkFirst(request, SHELL_CACHE))
+      return
+    }
+
+    /* AUDIO STREAMS */
+    if (
+      url.pathname.includes('/rest/stream') ||
+      url.pathname.includes('/rest/download')
+    ) {
+      event.respondWith(audioStrategy(request))
+      return
+    }
+
+    /* IMAGES */
+    if (
+      request.destination === 'image' ||
+      /\.(png|jpg|jpeg|webp|gif|svg)$/i.test(url.pathname)
+    ) {
+      event.respondWith(imageStrategy(request))
+      return
+    }
+
+    /* STATIC ASSETS */
+    if (isStaticAsset(url.pathname)) {
+      event.respondWith(cacheFirst(request, SHELL_CACHE))
+      return
+    }
+
+    /* DEFAULT */
+    event.respondWith(networkFirst(request, RUNTIME_CACHE))
+  }
+)
+
+/* -------------------------------------------------- */
+/* MESSAGES */
+/* -------------------------------------------------- */
+self.addEventListener(
+  'message',
+  event => {
+    switch (event.data || {}) {
+      case 'CLEAR_RUNTIME_CACHE':
+        event.waitUntil(caches.delete(RUNTIME_CACHE))
+        break;
+      case 'CLEAR_IMAGE_CACHE':
+        event.waitUntil(caches.delete(IMAGE_CACHE))
+        break;
+      case 'CLEAR_AUDIO_CACHE':
+        event.waitUntil(caches.delete(AUDIO_CACHE))
+        break;
+    }
+  }
+)
+
+/* -------------------------------------------------- */
+/* AUDIO STRATEGY */
+/* Network first / Cache fallback */
+/* -------------------------------------------------- */
+const audioStrategy = async (request) => {
+  // Do not cache range requests
+  if (request.headers.has('range'))
+    return fetch(request)
+
+  const cache = await caches.open(AUDIO_CACHE)
+
+  try {
+    const response = await fetch(request)
+
+    if (response && response.status === 200) {
+      const cached = await cache.match(request)
+
+      // Avoid overwriting entries managed by the app
+      if (!cached)
+        cache.put(
+          request,
+          response.clone()
+        ).catch(() => {})
+    }
+    return response
+  } catch (err) {
+    const cached = await cache.match(request)
+
+    if (cached)
+      return cached
+
+    throw err
+  }
+}
+
+/* -------------------------------------------------- */
+/* IMAGE STRATEGY */
+/* Cache first + background update */
+/* -------------------------------------------------- */
+const imageStrategy = async (request) => {
+  const
+    cache = await caches.open(IMAGE_CACHE),
+    cached = await cache.match(request),
+    networkFetch = fetch(request)
+      .then(async response => {
+        if (response && response.status === 200) {
+          await cache.put(request, response.clone())
+          await trimCache(IMAGE_CACHE, MAX_IMAGE_ENTRIES)
+        }
+        return response
+      })
+      .catch(() => null)
+
+  if (cached)
+    return cached
+
+  const network = await networkFetch
+  if (network)
+    return network
+
+  return new Response('', { status: 504 })
+}
+
+/* -------------------------------------------------- */
+/* NETWORK FIRST */
+/* -------------------------------------------------- */
+const networkFirst = async (request, cacheName) => {
+  const cache = await caches.open(cacheName)
+
+  try {
+    const response = await fetch(request)
+
+    if (response && response.status === 200)
+      cache.put(
+        request,
+        response.clone()
+      ).catch(() => {})
+
+    return response
+  } catch (err) {
+    const cached = await cache.match(request)
+
+    if (cached)
+      return cached
+
+    throw err
+  }
+
+}
+
+/* -------------------------------------------------- */
+/* CACHE FIRST */
+/* -------------------------------------------------- */
+const cacheFirst = async (request, cacheName) => {
+  const
+    cache = await caches.open(cacheName),
+    cached = await cache.match(request)
+
+  if (cached)
+    return cached
+
+  const response = await fetch(request)
+
+  if (response && response.status === 200)
+    cache.put(
+      request,
+      response.clone()
+    ).catch(() => {})
+
+  return response
+}
diff --git a/src/assets/fallback.svg b/src/assets/fallback.svg
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="512" height="512" fill="#999" version="1.1" viewBox="0 0 135.47 135.47" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#">
+  <rect width="100%" height="100%" fill="#6c757d"/>
+  <g transform="translate(0 -161.53)">
+    <g transform="matrix(1.0344 0 0 1.0869 -2.0685 -19.991)">
+    <rect x="9.9294" y="224.55" width="5.9939" height="23.366" rx="2.997" ry="2.997"/>
+    <rect x="19.849" y="215.2" width="5.9939" height="41.989" rx="2.997" ry="2.819"/>
+    <rect x="29.768" y="211.49" width="5.9939" height="49.213" rx="2.997" ry="2.997"/>
+    <rect x="39.688" y="202.01" width="5.9939" height="58.69" rx="2.997" ry="3.0381"/>
+    <rect x="49.607" y="198.3" width="5.9939" height="62.402" rx="2.997" ry="2.997"/>
+    <rect x="59.526" y="197.97" width="5.9939" height="62.733" rx="2.997" ry="2.997"/>
+    <rect x="69.446" y="201.81" width="5.9939" height="58.889" rx="2.997" ry="2.997"/>
+    <rect x="79.365" y="211.29" width="5.9939" height="49.412" rx="2.997" ry="2.997"/>
+    <rect x="89.285" y="211.75" width="5.9939" height="48.948" rx="2.997" ry="2.997"/>
+    <rect x="99.204" y="216.53" width="5.9939" height="44.176" rx="2.997" ry="2.997"/>
+    <rect x="109.12" y="223.55" width="5.9939" height="36.886" rx="2.997" ry="2.997"/>
+    <rect x="119.04" y="230.78" width="5.9939" height="22.372" rx="2.997" ry="2.997"/>
+    </g>
+  </g>
+</svg>+
\ No newline at end of file
diff --git a/src/audioController.ts b/src/audioController.ts
@@ -0,0 +1,576 @@
+import { sleep } from './utils'
+import { useCacheStore } from './store/cache'
+import { type ReplayGain, type Track, ReplayGainMode } from './types'
+
+// ---------------------------------------------------------------------------
+// Web Audio pipeline
+// ---------------------------------------------------------------------------
+// Each track is routed through a fixed graph:
+//
+//  MediaElementSource
+//    └─► volumeNode        (master volume, persisted in localStorage)
+//          └─► replayGainNode   (dB normalisation via ReplayGain metadata)
+//                └─► fadeNode        (short linear ramp on play/pause/seek)
+//                      └─► normalizerNode   (soft-knee compressor for peak limiting)
+//                            └─► AudioContext.destination
+
+type AudioPipeline = {
+  audio: HTMLAudioElement
+  volumeNode: GainNode
+  replayGainNode: GainNode
+  fadeNode: GainNode
+  normalizerNode: DynamicsCompressorNode
+  /** Disconnects all nodes and releases the audio element's src. */
+  dispose(): void
+}
+
+// ---------------------------------------------------------------------------
+// AudioController
+// ---------------------------------------------------------------------------
+// Owns a single AudioContext and one AudioPipeline at a time.
+// When a new track is loaded the current pipeline is disposed and replaced.
+// A pre-buffered HTMLAudioElement (this.buffer) is promoted to the new
+// pipeline, which eliminates the gap between consecutive tracks.
+
+export class AudioController {
+  /** Duration of the cross-fade / fade-in-out in seconds. */
+  private fadeTime = 0.3
+
+  /**
+   * Monotonically increasing counter that is bumped on every loadTrack() call.
+   * Async operations capture the token at start and abort if it no longer
+   * matches, preventing race conditions when tracks are changed rapidly.
+   */
+  private changeToken = 0
+
+  /**
+   * Pre-loaded HTMLAudioElement for the *next* track.
+   * Populated 15 s after a track starts playing so the browser has time to
+   * buffer the upcoming URL before it is needed.
+   */
+  private buffer: HTMLAudioElement | null = null
+
+  private replayGainMode = ReplayGainMode.None
+  private replayGain: ReplayGain | null = null
+
+  private _context: AudioContext | null = null
+
+  private pipeline: AudioPipeline | null = null
+
+  // ── Retry state ───────────────────────────────────────────────────────────
+  private lastLoadOptions: {
+    track: Track | null,
+    nextTrack?: Track | null,
+    paused?: boolean,
+    fade?: boolean
+  } | null = null
+  private retryCount = 0
+  private retryTimer: ReturnType<typeof setTimeout> | null = null
+  private readonly maxRetries = 4
+  /** Exponential back-off delays in ms: 2 s, 4 s, 8 s, 16 s */
+  private readonly retryDelays = [2_000, 4_000, 8_000, 16_000] as const
+
+  private get activePipeline(): AudioPipeline {
+    if (!this.pipeline)
+      this.pipeline = createPipeline(this.context, {})
+
+    return this.pipeline
+  }
+
+  private get context(): AudioContext {
+    if (!this._context)
+      this._context = new AudioContext()
+
+    return this._context
+  }
+
+  // ── Callbacks (assigned by the store via setupAudio) ──────────────────────
+  ontimeupdate = (_: number) => {}
+  ondurationchange = (_: number) => {}
+  onpause = () => {}
+  onplay = () => {}
+  onended = () => {}
+  onsuspend = () => {}
+  onerror = (_: MediaError | null) => {}
+  /** Fired on each retry attempt. */
+  onretrying = (_attempt: number, _max: number) => {}
+  /** Fired when all retries are exhausted without success. */
+  onfailed = () => {}
+  /** Fired when the browser stops fetching (stall watchdog armed). */
+  onstalled = () => {}
+
+  // ── Public accessors ──────────────────────────────────────────────────────
+
+  get audioElement(): HTMLAudioElement | undefined {
+    return this.activePipeline.audio
+  }
+
+  currentTime() {
+    return this.activePipeline.audio.currentTime
+  }
+
+  duration() {
+    return this.activePipeline.audio.duration
+  }
+
+  // ── Caching / buffering ───────────────────────────────────────────────────
+
+  /** Ask the cache store to persist the track URL in the service-worker cache. */
+  async cacheTrack(url: string) {
+    const cacheStore = useCacheStore()
+    cacheStore.cacheTrack(url!)
+  }
+
+  /**
+   * Create and pre-load an HTMLAudioElement for the given URL so it is
+   * ready to be promoted into the next pipeline without a network round-trip.
+   */
+  async setBuffer(url: string) {
+    this.buffer = new Audio()
+    this.buffer.crossOrigin = 'anonymous'
+    this.buffer.preload = 'auto'
+    this.buffer.src = url
+
+    try {
+      this.buffer.load()
+    } catch {}
+  }
+
+  // ── Volume & ReplayGain ───────────────────────────────────────────────────
+
+  /** Set master volume (0–1). Applied directly to the volumeNode gain. */
+  setVolume(value: number) {
+    this.activePipeline.volumeNode.gain.value = value
+  }
+
+  /**
+   * Switch ReplayGain mode and reconfigure the compressor accordingly.
+   * In None mode the compressor is effectively bypassed (ratio 1:1, 0 dB threshold).
+   */
+  setReplayGainMode(value: ReplayGainMode) {
+    this.replayGainMode = value
+    this.activePipeline.replayGainNode.gain.value = this.replayGainFactor()
+
+    if (value === ReplayGainMode.None) {
+      // Bypass the compressor: flat unity-gain passthrough
+      this.activePipeline.normalizerNode.threshold.value = 0
+      this.activePipeline.normalizerNode.knee.value = 0
+      this.activePipeline.normalizerNode.ratio.value = 1
+    } else {
+      // Soft-knee limiting: gently clamp peaks above -3 dBFS
+      this.activePipeline.normalizerNode.threshold.value = -3
+      this.activePipeline.normalizerNode.knee.value = 3
+      this.activePipeline.normalizerNode.ratio.value = 2
+      this.activePipeline.normalizerNode.attack.value = 0.01  // 10 ms attack
+      this.activePipeline.normalizerNode.release.value = 0.1  // 100 ms release
+    }
+  }
+
+  // ── Playback control ──────────────────────────────────────────────────────
+
+  /** Stop playback entirely and tear down the pipeline. */
+  async stop() {
+    this.changeToken++
+    this.cancelRetry()
+    this.disposePipeline(this.activePipeline)
+    this._context = null
+  }
+
+  /** Fade out, then pause the underlying HTMLAudioElement. */
+  async pause() {
+    const audio = this.activePipeline.audio
+    await this.fadeOut(this.fadeTime)
+    audio.pause()
+  }
+
+  /** Resume the AudioContext if suspended (auto-play policy), then fade in. */
+  async play() {
+    if (this.context.state === 'suspended')
+      await this.context.resume()
+
+    await this.activePipeline.audio.play()
+    await this.fadeIn(this.fadeTime / 2)
+  }
+
+  /** Seek to an absolute position (seconds). Fades out/in when playing. */
+  async seek(value: number) {
+    if (!this.activePipeline.audio.paused)
+      this.fadeOut(this.fadeTime / 2)
+
+    this.activePipeline.audio.currentTime = value
+
+    if (!this.activePipeline.audio.paused)
+      this.fadeIn(this.fadeTime / 2)
+  }
+
+  // ── Retry helpers ─────────────────────────────────────────────────────────
+
+  /**
+   * Re-load the last track without fade.
+   * Called by the retry scheduler and by the online event listener in the store.
+   */
+  retryCurrentTrack() {
+    if (this.lastLoadOptions)
+      void this.loadTrack({ ...this.lastLoadOptions, fade: false })
+  }
+
+  private cancelRetry() {
+    if (this.retryTimer !== null) {
+      clearTimeout(this.retryTimer)
+      this.retryTimer = null
+    }
+  }
+
+  private scheduleRetry() {
+    if (this.retryCount >= this.maxRetries) {
+      this.retryCount = 0
+      this.onfailed()
+      return
+    }
+
+    const delay = this.retryDelays[this.retryCount]
+    this.onretrying(this.retryCount + 1, this.maxRetries)
+    this.retryCount++
+    this.retryTimer = setTimeout(() => {
+      this.retryTimer = null
+      this.retryCurrentTrack()
+    }, delay)
+  }
+
+  // ── Track loading ─────────────────────────────────────────────────────────
+
+  /**
+   * Load a new track into the pipeline.
+   *
+   * Steps:
+   *  1. Bump changeToken so any in-flight loads are abandoned.
+   *  2. Re-use this.buffer if it already holds the right URL, otherwise
+   *     create a new buffered Audio element.
+   *  3. Build a fresh AudioPipeline around that element.
+   *  4. Optionally fade out the old pipeline before swapping.
+   *  5. Wire up all HTML5 audio event handlers.
+   *  6. Start playback (unless paused: true).
+   *  7. After 15 s, cache the current URL and start pre-buffering the next.
+   */
+  async loadTrack(options: {
+    track: Track | null,
+    nextTrack?: Track | null,
+    paused?: boolean,
+    fade?: boolean,
+  }) {
+    if (!options.track?.url)
+      return
+
+    // Capture token before any await so we can detect stale loads later
+    const token = ++this.changeToken
+
+    // Reset retry state for a fresh load request
+    this.cancelRetry()
+    this.retryCount = 0
+    this.lastLoadOptions = options // Save for retries
+
+    this.replayGain = options.track.replayGain ?? null
+
+    // Re-use existing buffer if possible to avoid redundant network requests
+    if (!this.buffer || this.buffer.src !== options.track.url || this.buffer.error) {
+      await this.setBuffer(options.track.url)
+      console.info('setBuffer(1):', options.track.url)
+    }
+
+    // Build the new pipeline using the buffered audio element
+    const nextPipeline = createPipeline(this.context, {
+      audio: this.buffer!,
+      volume: this.activePipeline.volumeNode.gain.value,
+      replayGain: this.replayGainFactor()
+    })
+
+    // Abort if another loadTrack() was called while we were awaiting
+    if (token !== this.changeToken) {
+      nextPipeline.dispose()
+      return
+    }
+
+    let playbackTransition = true
+
+    if (options.fade)
+      await this.fadeOut(this.fadeTime)
+
+    // Swap pipelines – old one is disposed after a short delay
+    this.replacePipeline(nextPipeline)
+    this.setReplayGainMode(this.replayGainMode)
+
+    const audio = this.activePipeline.audio
+
+    // ── Stall watchdog ────────────────────────────────────────────────────
+    // If the audio element stalls or runs out of buffer for >10 s while we
+    // expect it to be playing, check for cached data before retrying.
+    let stalledTimer: ReturnType<typeof setTimeout> | null = null
+
+    const clearStalledTimer = () => {
+      if (stalledTimer === null)
+        return
+
+      clearTimeout(stalledTimer);
+      stalledTimer = null
+    }
+
+    /**
+     * If the audio element already has enough buffered data ahead of the
+     * current position, play from it directly (no network needed).
+     * Otherwise schedule a retry with exponential back-off.
+     */
+    const playFromBufferOrRetry = () => {
+      const bufferedUpTo = (
+        (audio.buffered.length > 0)
+        ? audio.buffered.end(audio.buffered.length - 1)
+        : 0
+      )
+
+      if (bufferedUpTo > audio.currentTime + 1) {
+        // Data is already in memory – play from cache, skip the network entirely
+        void audio.play().catch(() => this.scheduleRetry())
+      } else {
+        this.scheduleRetry()
+      }
+    }
+
+    const armStalledTimer = () => {
+      if (playbackTransition)
+        return
+
+      clearStalledTimer()
+
+      if (token !== this.changeToken)
+        return
+
+      this.onstalled()
+
+      stalledTimer = setTimeout(() => {
+        stalledTimer = null
+
+        if (token !== this.changeToken || audio.paused)
+          return
+
+        playFromBufferOrRetry()
+      }, 10_000)
+    }
+
+    // ── Event handlers ────────────────────────────────────────────────────
+    audio.onerror = () => {
+      clearStalledTimer()
+
+      const code = audio.error?.code
+
+      if (code === MediaError.MEDIA_ERR_ABORTED) {
+        this.onerror(audio.error)
+      } else {
+        playFromBufferOrRetry()
+      }
+    }
+
+    audio.onended = () => { clearStalledTimer(); this.onended() }
+    audio.onpause = () => { clearStalledTimer(); this.onpause() }
+    audio.onplay = () => this.onplay()
+    audio.onstalled = armStalledTimer
+    audio.onwaiting = armStalledTimer
+    audio.onplaying = () => { playbackTransition = false; clearStalledTimer() }
+    audio.onseeking = () => { playbackTransition = true }
+    audio.ontimeupdate = () => this.ontimeupdate(audio.currentTime)
+
+    // Fire ondurationchange once the metadata has been parsed
+    audio.addEventListener(
+      'loadedmetadata',
+      () => {
+        const d = audio.duration
+        if (Number.isFinite(d) && d > 0)
+          this.ondurationchange(d)
+      }
+    )
+
+    // Force metadata load if not already ready
+    if (audio.readyState < 1) {
+      try {
+        audio.load()
+      } catch {}
+    }
+
+    if (!options.paused) {
+      try {
+        await this.play()
+      } catch (err: any) {
+        // AbortError is expected if play() is interrupted by a rapid track change
+        if (err.name !== 'AbortError') throw err
+      }
+    }
+
+    // After 15 s, cache the playing track and pre-buffer the next one
+    setTimeout(() => {
+      if (token === this.changeToken && options.track?.url) {
+        this.cacheTrack(options.track.url)
+
+        if (options.nextTrack?.url) {
+          this.setBuffer(options.nextTrack.url)
+          console.info('setBuffer(2):', options.nextTrack.url)
+        }
+      }
+    }, Math.min(15000, (this.activePipeline.audio.duration || 30) * 0.5 * 1000))
+  }
+
+  // ── Private helpers ───────────────────────────────────────────────────────
+
+  /** Swap the active pipeline, disposing the old one. */
+  private replacePipeline(next: AudioPipeline) {
+    this.disposePipeline(this.activePipeline)
+    this.pipeline = next
+  }
+
+  /**
+   * Remove all event listeners from a pipeline's audio element and schedule
+   * its Web Audio nodes for disconnection after a short grace period so that
+   * any in-progress audio chunk can finish decoding.
+   */
+  private disposePipeline(pipeline: AudioPipeline) {
+    pipeline.audio.onended = null
+    pipeline.audio.onerror = null
+    pipeline.audio.onpause = null
+    pipeline.audio.onplay = null
+    pipeline.audio.onplaying = null
+    pipeline.audio.onstalled = null
+    pipeline.audio.onwaiting = null
+    pipeline.audio.ontimeupdate = null
+    pipeline.audio.ondurationchange = null
+
+    // Small delay to let the current audio frame finish before disconnecting
+    sleep(500).then(() => pipeline.dispose())
+  }
+
+  /** Linearly ramp the fade node from its current value to 1 over `duration` seconds. */
+  private async fadeIn(duration = 0) {
+    const
+      gain = this.activePipeline.fadeNode.gain,
+      now = this.context.currentTime
+
+    gain.cancelScheduledValues(0)
+    gain.setValueAtTime(gain.value, now)
+    gain.linearRampToValueAtTime(1, now + duration)
+    await sleep(duration * 1000)
+  }
+
+  /** Linearly ramp the fade node from its current value to 0 over `duration` seconds. */
+  private async fadeOut(duration = 0) {
+    const
+      gain = this.activePipeline.fadeNode.gain,
+      now = this.context.currentTime
+
+    gain.cancelScheduledValues(0)
+    gain.setValueAtTime(gain.value, now)
+    gain.linearRampToValueAtTime(0, now + duration)
+    await sleep(duration * 1000)
+  }
+
+  /**
+   * Calculate the linear gain multiplier to apply for the current ReplayGain
+   * mode, respecting the per-track or per-album peak so we never clip.
+   *
+   * Formula:  gain_linear = min(10^((dBGain + preAmp) / 20), 1 / peak)
+   * The preAmp (+6 dB) adds a fixed positive offset so quiet tracks are
+   * brought up without requiring the dBGain value to be abnormally large.
+   */
+  private replayGainFactor(): number {
+    if (this.replayGainMode === ReplayGainMode.None || !this.replayGain)
+      return 1
+
+    const
+      gain = (
+        (this.replayGainMode === ReplayGainMode.Track)
+        ? this.replayGain.trackGain
+        : this.replayGain.albumGain
+      ),
+      peak = (
+        (this.replayGainMode === ReplayGainMode.Track)
+        ? this.replayGain.trackPeak
+        : this.replayGain.albumPeak
+      )
+
+    if (!Number.isFinite(gain) || !Number.isFinite(peak) || peak <= 0)
+      return 1 // Fall back to unity gain if metadata is missing or invalid
+
+    const preAmp = 6 // dB – boosts overall loudness before peak limiting
+    return Math.min(
+      Math.pow(10, (gain + preAmp) / 20),
+      1 / peak
+    )
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Pipeline factory
+// ---------------------------------------------------------------------------
+
+/**
+ * Create a fresh Web Audio pipeline for a given HTMLAudioElement.
+ * All nodes are connected in series and wired to the AudioContext destination.
+ *
+ * @param context  - Shared AudioContext (created once per AudioController)
+ * @param options  - Optional seed values; defaults to a silent, un-sourced pipeline
+ */
+function createPipeline(
+  context: AudioContext,
+  {
+    audio = new Audio(),
+    volume = 1,
+    replayGain = 1
+  }: {
+    audio?: HTMLAudioElement
+    volume?: number
+    replayGain?: number
+  }
+): AudioPipeline {
+  audio.playbackRate = 1
+
+  // Wrap the HTMLAudioElement in a Web Audio source node
+  const
+    source = context.createMediaElementSource(audio),
+    volumeNode = context.createGain(),
+    replayGainNode = context.createGain(),
+    fadeNode = context.createGain(),
+    normalizerNode = context.createDynamicsCompressor()
+
+  // Set initial gain values
+  volumeNode.gain.value = volume
+  replayGainNode.gain.value = replayGain
+  fadeNode.gain.value = 1            // Fully open – fades are applied at runtime
+  normalizerNode.threshold.value = 0 // Flat until setReplayGainMode() configures it
+
+  // Wire the signal chain
+  source
+    .connect(volumeNode)
+    .connect(replayGainNode)
+    .connect(fadeNode)
+    .connect(normalizerNode)
+    .connect(context.destination)
+
+  /** Disconnect every node and free the audio element's src. */
+  const dispose = () => {
+    audio.pause()
+    source.disconnect()
+    volumeNode.disconnect()
+    replayGainNode.disconnect()
+    fadeNode.disconnect()
+    normalizerNode.disconnect()
+    audio.src = ''
+
+    try {
+      audio.load()
+    } catch {}
+  }
+
+  return {
+    audio,
+    volumeNode,
+    replayGainNode,
+    fadeNode,
+    normalizerNode,
+    dispose
+  }
+}
diff --git a/src/components/AlbumList.vine.ts b/src/components/AlbumList.vine.ts
@@ -0,0 +1,85 @@
+import { computed } from 'vue'
+
+import type { Album } from '../types'
+import { useSubsonicApi } from '../subsonicApi'
+
+import { useCacheStore } from '../store/cache'
+import { usePlayerStore } from '../store/player'
+import { useFavouriteStore } from '../store/favourite'
+import { sleep } from '../utils'
+
+export const AlbumList = ({
+  items,
+  tileSize = 200,
+  allowHScroll = false,
+  twoRows = false,
+  titleOnly = false,
+}: {
+  items: Album[],
+  tileSize?: number,
+  allowHScroll?: boolean,
+  twoRows?: boolean,
+  titleOnly?: boolean,
+}) => {
+  const
+    subsonicApi = useSubsonicApi(),
+    favouriteStore = useFavouriteStore(),
+    playerStore = usePlayerStore(),
+    cacheStore = useCacheStore(),
+
+    isFavourite = (id: string) => favouriteStore.get('album', id),
+    dragstart = (id: string, event: DragEvent) => (event.dataTransfer?.setData('application/x-album-id', id)),
+
+    playNow = async(id: string) => {
+      playerStore.setShuffle(false)
+      return playerStore.playTrackList((await subsonicApi.getAlbumDetails(id)).tracks!)
+    },
+
+    toggleFavourite = async(id: string) => {
+      favouriteStore.toggle('album', id)
+      await sleep(300)
+
+      const album = await subsonicApi.getAlbumDetails(id)
+      if (!album) return
+      if (isFavourite(id)) {
+        await cacheStore.cacheAlbum(album)
+      } else {
+        await cacheStore.clearAlbumCache(album)
+      }
+    }
+
+  return vine`
+    <Tiles :tile-size="tileSize" :allow-h-scroll="allowHScroll" :two-rows="twoRows">
+      <Tile
+        v-for="(item, index) in items"
+        :key="item.id || index"
+        :to="{ name: 'album', params: { id: item.id } }"
+        :title="item.name || 'Unknown Album'"
+        :image="item.image || ''"
+        draggable="true"
+        :title-only="titleOnly"
+        @dragstart="dragstart(item.id, $event)"
+      >
+        <template #text>
+          <span v-for="(artist, artistIndex) in item.artists" :key="artist.id">
+            <span v-if="artistIndex > 0" class="text-muted">, </span>
+            <router-link
+              :to="{ name: 'artist', params: { id: artist.id } }"
+              class="text-muted"
+            >
+              {{ artist.name }}
+            </router-link>
+          </span>
+        </template>
+        <template v-if="tileSize > 79" #context-menu>
+          <DropdownItem icon="play" class="on-top" @click="playNow(item.id)">
+            Play
+          </DropdownItem>
+          <DropdownItem :icon="isFavourite(item.id) ? 'heart-fill' : 'heart'" class="on-top" @click.stop="toggleFavourite(item.id)">
+            Ravorite
+          </DropdownItem>
+        </template>
+      </Tile>
+    </Tiles>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/ArtistList.vine.ts b/src/components/ArtistList.vine.ts
@@ -0,0 +1,41 @@
+import type { Artist } from '../types'
+import { useFavouriteStore } from '../store/favourite'
+
+export const ArtistList = ({
+  items,
+  allowHScroll = false,
+  tileSize = 200,
+}: {
+  items: Artist[],
+  allowHScroll?: boolean,
+  tileSize?: number,
+}) => {
+  const
+    favouriteStore = useFavouriteStore(),
+    isFavourite = (id: string) => favouriteStore.get('artist', id),
+    toggleFavourite = async (id: string) => favouriteStore.toggle('artist', id)
+
+  return vine`
+    <Tiles :tile-size="tileSize" :allow-h-scroll="allowHScroll">
+      <Tile
+        v-for="item in items"
+        :key="item.id"
+        :to="{name: 'artist', params: { id: item.id } }"
+        :title="item.name"
+        :image="item.image"
+      >
+        <template #text>
+          <strong>{{ item.albumCount }}</strong> albums
+        </template>
+        <template #context-menu>
+          <DropdownItem
+            :icon="isFavourite(item.id) ? 'heart-fill' : 'heart'"
+            @click.stop="toggleFavourite(item.id)"
+          >
+            Favorite
+          </DropdownItem>
+        </template>
+      </Tile>
+    </Tiles>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/ConfirmDialog.vine.ts b/src/components/ConfirmDialog.vine.ts
@@ -0,0 +1,49 @@
+import { ref } from 'vue'
+
+export interface ConfirmDialogExpose {
+  open: (title: string, message: string) => Promise<boolean>
+}
+
+export const ConfirmDialog = () => {
+  let
+    resolver: ((value: boolean) => void) | null = null
+
+  const
+    visible = ref<boolean>(false),
+    title = ref<string>(''),
+    message = ref<string>(''),
+
+    open = (t: string, m: string) => {
+      title.value = t
+      message.value = m
+      visible.value = true
+
+      return new Promise<boolean>((resolve) => {
+        resolver = resolve
+      })
+    },
+
+    close = (result: boolean) => {
+      visible.value = false
+      resolver?.(result)
+      resolver = null
+    },
+
+    confirm = () => close(true),
+    cancel = () => close(false)
+
+  vineExpose({ open })
+
+  return vine`
+    <dialog :open="visible" @click="cancel()">
+      <article @click.stop>
+        <h3>{{ title }}</h3>
+        <p>{{ message }}</p>
+        <footer>
+          <button @click="cancel()">Cancel</button>
+          <button class="contrast" @click="confirm()">Confirm</button>
+        </footer>
+      </article>
+    </dialog>
+  `
+}
diff --git a/src/components/ContextMenu.vine.ts b/src/components/ContextMenu.vine.ts
@@ -0,0 +1,61 @@
+import { ref, reactive } from 'vue'
+import { useEventListener } from '@vueuse/core'
+
+export const ContextMenu = ({ enabled = true }: { enabled?: boolean }) => {
+  const
+    el = ref<Element | null>(null),
+    visible = ref<boolean>(false),
+    position = ref({ top: 0, left: 0 })
+
+  useEventListener(document, 'contextmenu', (event) => {
+    if (
+      enabled &&
+      el.value &&
+      event.target &&
+      (event.target === el.value || el.value.contains(event.target as Element))
+    ) {
+      event.preventDefault()
+      position.value = { top: event.offsetY, left: event.offsetX }
+      visible.value = true
+    } else {
+      visible.value = false
+    }
+  })
+
+  useEventListener(document, 'click', () => {
+    visible.value = false
+  })
+
+  useEventListener(document, 'keyup', (event) => {
+    if (event.key === 'Escape')
+      visible.value = false
+  })
+
+  vineStyle.scoped(`
+    .dropdown-menu {
+      min-width: 3rem !important;
+      z-index: 3000 !important;
+    }
+    .dropdown-menu .dropdown-item {
+      z-index: 9000 !important;
+    }
+  `)
+
+  const style = reactive({
+    left: `${position.value.left}px`,
+    top: `${position.value.top}px`,
+  })
+
+  return vine`
+    <div ref="el">
+      <slot />
+      <ul
+        v-if="enabled && visible"
+        class="dropdown-menu position-absolute show"
+        :style="style"
+      >
+        <slot name="context-menu" />
+      </ul>
+    </div>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/Dropdown.vine.ts b/src/components/Dropdown.vine.ts
@@ -0,0 +1,42 @@
+export const Dropdown = ({
+  disabled = false,
+  toggleClass = '',
+}: {
+  toggleClass?: string,
+  disabled?: boolean,
+}) => {
+  return vine`
+    <details class="dropdown">
+      <summary role="button" :class="toggleClass">
+        <slot name="button" />
+      </summary>
+      <ul>
+        <slot />
+      </ul>
+    </details>
+  `
+}
+
+export const DropdownItem = (props: {
+  icon?: string,
+  disabled?: boolean,
+  href?: string,
+  class?: any,
+  divider?: boolean,
+}) => {
+  return vine`
+    <li v-if="divider" class="divider" />
+    <template v-else>
+      <li :role="$attrs.onClick ? 'button': undefined" :class="class">
+        <a v-if="href" :href="href" v-bind="$attrs">
+          <slot />
+          <Icon v-if="icon" :icon="icon"/>
+        </a>
+        <template v-else>
+          <slot />
+          <Icon v-if="icon" :icon="icon"/>
+        </template>
+      </li>
+    </template>
+  `
+}
diff --git a/src/components/EditPlaylistModal.vine.ts b/src/components/EditPlaylistModal.vine.ts
@@ -0,0 +1,82 @@
+import { ref, watch } from 'vue'
+
+import type { Playlist } from '../types'
+import { SwitchInput } from './SwitchInput.vine'
+
+export const EditPlaylistModal = ({
+  playlist,
+  mode = 'edit',
+}: {
+  playlist: Playlist,
+  mode?: 'create' | 'edit',
+}) => {
+  const emit = vineEmits(['close', 'update-playlist', 'create-playlist'])
+
+  const
+    local = ref(playlist ?? {
+      name: '',
+      comment: '',
+      isPublic: false
+    }),
+
+    save = () => {
+      if (!local.value.name?.trim()) {
+        alert('Name cannot be empty')
+        return
+      }
+
+      if (mode !== 'edit') {
+        emit('create-playlist', local.value.name)
+      } else {
+        emit('update-playlist', { ...local.value })
+      }
+
+      emit('close')
+    }
+
+  watch(
+    () => playlist,
+    (newVal) => {
+      local.value = newVal ?? {
+        name: '',
+        comment: '',
+        isPublic: false
+      }
+    },
+    { immediate: true }
+  )
+
+  return vine`
+    <Teleport to="#dialogBoxes">
+      <dialog open @click="$emit('close')">
+        <article @click.stop>
+          <h2>{{ mode === 'edit' ? 'Edit Playlist' : 'New Playlist' }}</h2>
+          <form>
+            <fieldset>
+              <label>
+                Name
+                <input v-model="local.name" type="text">
+              </label>
+              <label v-if="mode === 'edit'">
+                Comment
+                <textarea v-model="local.comment" class="form-control" />
+              </label>
+              <label v-if="mode === 'edit'">
+                <SwitchInput v-model="local.isPublic" />
+                Public
+              </label>
+            </fieldset>
+            <footer>
+              <button @click.stop="$emit('close')">
+                Cancel
+              </button>
+              <button @click.stop="save" class="contrast">
+                {{ mode === 'edit' ? 'Save' : 'Create' }}
+              </button>
+            </footer>
+          </form>
+        </article>
+      </dialog>
+    </Teleport>
+  `
+}
diff --git a/src/components/EmptyIndicator.vine.ts b/src/components/EmptyIndicator.vine.ts
@@ -0,0 +1,12 @@
+export const EmptyIndicator = ({ label }: { label?: string }) => {
+  return vine`
+    <div class="empty">
+      <Icon icon="stack" />
+      <div class="color-muted">
+        <slot>
+          {{ label ?? 'Empty' }}
+        </slot>
+      </div>
+    </div>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/Header.vine.ts b/src/components/Header.vine.ts
@@ -0,0 +1,26 @@
+import { computed } from 'vue'
+
+import fallbackImage from '../assets/fallback.svg';
+
+export const Header = ({ hover, image = fallbackImage }: {
+  image?: string,
+  hover?: string,
+}) => {
+  const emit = vineEmits([ 'click' ])
+  const style = computed(() => ({ '--backdrop-image': `url('${image}')` }))
+
+  return vine`
+    <div class="header" :style="style">
+      <div class="backdrop" />
+      <img
+        class="album-cover"
+        :src="image"
+        :title="hover"
+        @click="$emit('click')"
+      >
+      <div class="details">
+        <slot />
+      </div>
+    </div>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/Icon.vine.ts b/src/components/Icon.vine.ts
@@ -0,0 +1,123 @@
+import { computed } from 'vue'
+import { ReplayGainMode } from '../types'
+
+export const Icon = ({ icon, mode = ReplayGainMode.None }: { icon: string, mode?: ReplayGainMode }) => {
+  const svg = computed(() => {
+    if (icon !== 'replaygain') {
+      return (
+        (Array.isArray(icons[icon]))
+        ? { viewBox: `0 0 ${icons[icon][0]} ${icons[icon][0]}`, data: icons[icon][1] }
+        : { viewBox: '0 0 24 24', data: icons[icon] }
+      )
+    } else {
+      const 
+        firstChar = (
+          (mode === ReplayGainMode.None)
+          ? 'RG'
+          : 'R'
+        ),
+        secondChar = (
+          (mode === ReplayGainMode.Album)
+          ? 'A'
+          : (
+            (mode === ReplayGainMode.Track)
+            ? 'T'
+            : null
+          )
+        )
+      return {
+        viewBox: '0 0 24 24',
+        data: `
+          <text x="0" y="50%" dominant-baseline="central" text-anchor="start" font-family="Arial, sans-serif" font-weight="bold" font-size="16">${firstChar}</text>
+          ${secondChar !== null ? `<text x="52%" y="72%" dominant-baseline="central" font-family="Arial, sans-serif" font-weight="bold" font-size="13">${secondChar}</text>` : ''}
+        `
+      }
+    }
+  })
+
+  return vine`
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      role="img"
+      focusable="false"
+      aria-hidden="true"
+      width="1em"
+      height="1em"
+      fill="currentColor"
+      preserveAspectRatio="xMidYMid meet"
+      :viewBox="svg.viewBox"
+      class="icon"
+      v-bind="$attrs"
+      v-html="svg.data"
+    />
+  `
+}
+
+const icons = {
+  add: `<path d="M11 13H6q-.425 0-.712-.288T5 12t.288-.712T6 11h5V6q0-.425.288-.712T12 5t.713.288T13 6v5h5q.425 0 .713.288T19 12t-.288.713T18 13h-5v5q0 .425-.288.713T12 19t-.712-.288T11 18z" />`,
+  albums: `<path d="M12 16.5q1.875 0 3.188-1.312T16.5 12t-1.312-3.187T12 7.5T8.813 8.813T7.5 12t1.313 3.188T12 16.5m-.712-3.787Q11 12.425 11 12t.288-.712T12 11t.713.288T13 12t-.288.713T12 13t-.712-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22" />`,
+  artists: `<path d="M15.725 19.275Q15 18.55 15 17.5t.725-1.775T17.5 15q.2 0 .45.038t.55.162V10H22v2h-2v5.5q0 1.05-.725 1.775T17.5 20t-1.775-.725m-7.55-8.45Q7 9.65 7 8t1.175-2.825T11 4t2.825 1.175T15 8t-1.175 2.825T11 12t-2.825-1.175M3 20v-2.8q0-.875.438-1.575T4.6 14.55q1.55-.775 3.15-1.162T11 13q1.05 0 2.088.163t2.087.487q-1.65 1-2.05 2.863t.65 3.487z" />`,
+  cache: `<path d="M8 17h8q.425 0 .713-.288T17 16t-.288-.712T16 15H8q-.425 0-.712.288T7 16t.288.713T8 17m3-6.85l-.9-.875Q9.825 9 9.413 9t-.713.3q-.275.275-.275.7t.275.7l2.6 2.6q.3.3.7.3t.7-.3l2.6-2.6q.275-.275.287-.687T15.3 9.3q-.275-.275-.687-.288t-.713.263l-.9.875V7q0-.425-.288-.712T12 6t-.712.288T11 7zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22" />`,
+  cached: `<path d="M9 17h6q.425 0 .713-.288T16 16t-.288-.712T15 15H9q-.425 0-.712.288T8 16t.288.713T9 17m1.95-5.8L9.5 9.75q-.275-.275-.7-.275t-.7.275t-.275.7t.275.7l2.15 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.288-.713t-.313-.687t-.712-.262t-.688.287zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22" />`,
+  discover: `<path d="M16 20q-.425 0-.712-.288T15 19v-5q0-.425.288-.712T16 13h5q.425 0 .713.288T22 14v5q0 .425-.288.713T21 20zm-4-9q-.425 0-.712-.288T11 10V5q0-.425.288-.712T12 4h9q.425 0 .713.288T22 5v5q0 .425-.288.713T21 11zm-9 9q-.425 0-.712-.288T2 19v-5q0-.425.288-.712T3 13h9q.425 0 .713.288T13 14v5q0 .425-.288.713T12 20zm0-9q-.425 0-.712-.288T2 10V5q0-.425.288-.712T3 4h5q.425 0 .713.288T9 5v5q0 .425-.288.713T8 11z" />`,
+  download: `<path d="M13 13.15V10q0-.425-.288-.712T12 9t-.712.288T11 10v3.15l-.9-.875Q9.825 12 9.413 12t-.713.3q-.275.275-.275.7t.275.7l2.6 2.6q.3.3.7.3t.7-.3l2.6-2.6q.275-.275.287-.687T15.3 12.3q-.275-.275-.687-.288t-.713.263zM6 22q-.825 0-1.412-.587T4 20V8.825q0-.4.15-.762t.425-.638l4.85-4.85q.275-.275.638-.425t.762-.15H18q.825 0 1.413.588T20 4v16q0 .825-.587 1.413T18 22z" />`,
+  edit: `<path d="M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-1 2q-.425 0-.712-.288T3 20v-2.425q0-.4.15-.763t.425-.637L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.437.65T21 6.4q0 .4-.138.763t-.437.662l-12.6 12.6q-.275.275-.638.425t-.762.15zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z" />`,
+  genres: `<path d="M12.125 17.125Q13 16.25 13 15V8h3V6h-4.5v6.4q-.35-.2-.725-.3T10 12q-1.25 0-2.125.875T7 15t.875 2.125T10 18t2.125-.875M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22" />`,
+  globe: `<path d="M8.1 21.213q-1.825-.788-3.175-2.138T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22t-3.9-.788M11 19.95V18q-.825 0-1.412-.587T9 16v-1l-4.8-4.8q-.075.45-.137.9T4 12q0 3.025 1.988 5.3T11 19.95m6.9-2.55q1.025-1.125 1.563-2.512T20 12q0-2.45-1.362-4.475T15 4.6V5q0 .825-.587 1.413T13 7h-2v2q0 .425-.288.713T10 10H8v2h6q.425 0 .713.288T15 13v3h1q.65 0 1.175.388T17.9 17.4" />`,
+  back: `<path d="m7.825 13l4.9 4.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-6.6-6.6q-.15-.15-.213-.325T4.426 12t.063-.375t.212-.325l6.6-6.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L7.825 11H19q.425 0 .713.288T20 12t-.288.713T19 13z" />`,
+  chart: `<path d="m10.45 12.975l1.3 1.3q.275.275.7.275t.7-.275l2.85-2.85v.6q0 .425.288.7T17 13t.713-.287T18 12V9q0-.425-.288-.712T17 8h-3.025q-.425 0-.7.288T13 9t.288.713T14 10h.575l-2.125 2.15l-1.3-1.3q-.275-.3-.7-.3t-.7.3L6.7 13.9q-.3.275-.3.7t.3.7q.275.3.7.3t.7-.3zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z" />`,
+  heart: `<path d="M11.288 20.2q-.363-.125-.638-.4l-1.725-1.575q-2.65-2.425-4.787-4.812T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.537t2.5-.563q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125t-.712-.125M11.05 6.75q-.725-1.025-1.55-1.562t-2-.538q-1.5 0-2.5 1t-1 2.5q0 1.3.925 2.763t2.213 2.837t2.65 2.575T12 18.3q.85-.775 2.213-1.975t2.65-2.575t2.212-2.837T20 8.15q0-1.5-1-2.5t-2.5-1q-1.175 0-2 .538T12.95 6.75q-.175.25-.425.375T12 7.25t-.525-.125t-.425-.375m.95 4.725" />`,
+  'heart-fill': `<path d="M11.288 20.2q-.363-.125-.638-.4l-1.725-1.575q-2.65-2.425-4.787-4.812T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.325 0 2.5.562t2 1.538q.825-.975 2-1.537t2.5-.563q2.35 0 3.925 1.575T22 8.15q0 2.875-2.125 5.275T15.05 18.25l-1.7 1.55q-.275.275-.637.4t-.713.125t-.712-.125" />`,
+  'heart-artist': `<path d="M9.175 10.825Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175M6 20q-.825 0-1.412-.587T4 18v-.8q0-.85.438-1.562T5.6 14.55q1.25-.625 2.488-.987t2.587-.488q.575-.05.925.425t.225 1.075q-.025.125-.025.238v.237q0 .75.263 1.488t.912 1.387l.4.375q.475.475.213 1.088T12.65 20zm11.2-.7l-2.8-2.8q-.325-.325-.462-.7t-.138-.75q0-.8.575-1.425T15.85 13q.7 0 1.1.325t.95.875q.5-.5.913-.85T19.95 13q.925 0 1.488.638T22 15.074q0 .375-.15.75t-.45.675l-2.8 2.8q-.3.3-.7.3t-.7-.3" />`,
+  info: `<path d="M12.713 16.713Q13 16.425 13 16v-4q0-.425-.288-.712T12 11t-.712.288T11 12v4q0 .425.288.713T12 17t.713-.288m0-8Q13 8.425 13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8" />`,
+  link: `<path d="M7.95 21q-2.05 0-3.5-1.45T3 16.05q0-1 .375-1.9t1.075-1.6l2.625-2.625q.3-.3.713-.3t.712.3t.3.7t-.3.7l-2.65 2.65q-.425.425-.637.963T5 16.05q0 1.225.863 2.088T7.95 19q.575 0 1.125-.213t.975-.637l2.625-2.65q.3-.275.7-.275t.7.3t.3.7t-.3.7L11.45 19.55q-.7.7-1.6 1.075T7.95 21m1.25-6.2q-.3-.3-.3-.712t.3-.713L13.375 9.2q.3-.3.713-.3t.712.3t.3.713t-.3.712L10.625 14.8q-.3.3-.712.3t-.713-.3m6.3-.725q-.3-.3-.3-.7t.3-.7l2.65-2.625q.425-.425.625-.95t.2-1.1q0-1.25-.85-2.125T16.025 5q-.575 0-1.112.213t-.963.637L11.325 8.5q-.3.3-.7.3t-.7-.3t-.3-.712t.3-.713L12.55 4.45q.7-.7 1.6-1.075T16.05 3q2.05 0 3.488 1.45t1.437 3.525q0 .975-.363 1.875t-1.062 1.6l-2.625 2.625q-.3.3-.712.3t-.713-.3" />`,
+  logout: `<path d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h6q.425 0 .713.288T12 4t-.288.713T11 5H5v14h6q.425 0 .713.288T12 20t-.288.713T11 21zm12.175-8H10q-.425 0-.712-.288T9 12t.288-.712T10 11h7.175L15.3 9.125q-.275-.275-.275-.675t.275-.7t.7-.313t.725.288L20.3 11.3q.3.3.3.7t-.3.7l-3.575 3.575q-.3.3-.712.288t-.713-.313q-.275-.3-.262-.712t.287-.688z" />`,
+  lastfm: [32,`<path d="M14.23 22.512l-1.1-2.99c-1.137 1.176-2.708 1.926-4.454 1.992l-0.012 0c-2.371 0-4.055-2.061-4.055-5.36 0-4.227 2.13-5.739 4.226-5.739 3.025 0 3.986 1.959 4.811 4.468l1.1 3.436c1.1 3.332 3.161 6.012 9.106 6.012 4.261 0 7.148-1.305 7.148-4.741 0-2.784-1.581-4.226-4.538-4.914l-2.197-0.481c-1.512-0.344-1.959-0.963-1.959-1.994 0-1.168 0.927-1.855 2.44-1.855 1.65 0 2.543 0.619 2.68 2.096l3.436-0.412c-0.275-3.092-2.405-4.365-5.911-4.365-3.093 0-6.116 1.169-6.116 4.915 0 2.338 1.134 3.814 3.986 4.501l2.337 0.55c1.753 0.413 2.336 1.134 2.336 2.13 0 1.271-1.238 1.788-3.575 1.788-0.12 0.009-0.26 0.015-0.401 0.015-2.619 0-4.806-1.847-5.33-4.309l-0.006-0.036-1.134-3.438c-1.444-4.466-3.746-6.116-8.316-6.116-5.053 0-7.732 3.196-7.732 8.625 0 5.225 2.68 8.043 7.491 8.043 0.145 0.009 0.314 0.014 0.485 0.014 1.994 0 3.826-0.692 5.27-1.848l-0.017 0.013z" />`],
+  'note-plus': `<path d="M9.175 19.825Q8 18.65 8 17t1.175-2.825T12 13q.575 0 1.063.138t.937.412V4q0-.425.288-.712T15 3h4q.425 0 .713.288T20 4v2q0 .425-.288.713T19 7h-3v10q0 1.65-1.175 2.825T12 21t-2.825-1.175M7 8H5q-.425 0-.712-.288T4 7t.288-.712T5 6h2V4q0-.425.288-.712T8 3t.713.288T9 4v2h2q.425 0 .713.288T12 7t-.288.713T11 8H9v2q0 .425-.288.713T8 11t-.712-.288T7 10z" />`,
+  pause: `<path d="M16 19q-.825 0-1.412-.587T14 17V7q0-.825.588-1.412T16 5t1.413.588T18 7v10q0 .825-.587 1.413T16 19m-8 0q-.825 0-1.412-.587T6 17V7q0-.825.588-1.412T8 5t1.413.588T10 7v10q0 .825-.587 1.413T8 19" />`,
+  play: `<path d="M8 17.175V6.825q0-.425.3-.713t.7-.287q.125 0 .263.037t.262.113l8.15 5.175q.225.15.338.375t.112.475t-.112.475t-.338.375l-8.15 5.175q-.125.075-.262.113T9 18.175q-.4 0-.7-.288t-.3-.712" />`,
+  playlist: `<path d="M4 16q-.425 0-.712-.288T3 15t.288-.712T4 14h6q.425 0 .713.288T11 15t-.288.713T10 16zm0-4q-.425 0-.712-.288T3 11t.288-.712T4 10h10q.425 0 .713.288T15 11t-.288.713T14 12zm0-4q-.425 0-.712-.288T3 7t.288-.712T4 6h10q.425 0 .713.288T15 7t-.288.713T14 8zm12.775 12.475q-.125.075-.25.075t-.25-.05t-.2-.162t-.075-.263v-6.15q0-.15.075-.262t.2-.163t.25-.05t.25.075l4.6 3.05q.125.075.175.188t.05.237t-.05.238t-.175.187z" />`,
+  'playlist-add': `<path d="M4 16q-.425 0-.712-.288T3 15t.288-.712T4 14h5q.425 0 .713.288T10 15t-.288.713T9 16zm0-4q-.425 0-.712-.288T3 11t.288-.712T4 10h9q.425 0 .713.288T14 11t-.288.713T13 12zm0-4q-.425 0-.712-.288T3 7t.288-.712T4 6h9q.425 0 .713.288T14 7t-.288.713T13 8zm12.288 11.713Q16 19.425 16 19v-3h-3q-.425 0-.712-.288T12 15t.288-.712T13 14h3v-3q0-.425.288-.712T17 10t.713.288T18 11v3h3q.425 0 .713.288T22 15t-.288.713T21 16h-3v3q0 .425-.288.713T17 20t-.712-.288" />`,
+  'playlist-remove': `<path d="m17 19.4l-1.9 1.9q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l1.9-1.9l-1.9-1.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l1.9 1.9l1.9-1.9q.275-.275.7-.275t.7.275t.275.7t-.275.7L18.4 18l1.9 1.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275zM4 16q-.425 0-.712-.288T3 15t.288-.712T4 14h5q.425 0 .713.288T10 15t-.288.713T9 16zm0-4q-.425 0-.712-.288T3 11t.288-.712T4 10h9q.425 0 .713.288T14 11t-.288.713T13 12zm0-4q-.425 0-.712-.288T3 7t.288-.712T4 6h9q.425 0 .713.288T14 7t-.288.713T13 8z" />`,
+  plus: `<path d="M4 16q-.425 0-.712-.288T3 15t.288-.712T4 14h5q.425 0 .713.288T10 15t-.288.713T9 16zm0-4q-.425 0-.712-.288T3 11t.288-.712T4 10h9q.425 0 .713.288T14 11t-.288.713T13 12zm0-4q-.425 0-.712-.288T3 7t.288-.712T4 6h9q.425 0 .713.288T14 7t-.288.713T13 8zm12.288 11.713Q16 19.425 16 19v-3h-3q-.425 0-.712-.288T12 15t.288-.712T13 14h3v-3q0-.425.288-.712T17 10t.713.288T18 11v3h3q.425 0 .713.288T22 15t-.288.713T21 16h-3v3q0 .425-.288.713T17 20t-.712-.288" />`,
+  radio: `<path d="M4 22q-.825 0-1.412-.587T2 20V6.65L15.9 1l.65 1.65L8.3 6H20q.825 0 1.413.588T22 8v12q0 .825-.587 1.413T20 22zm5.775-3.725q.725-.725.725-1.775t-.725-1.775T8 14t-1.775.725T5.5 16.5t.725 1.775T8 19t1.775-.725M4 11h12V9h2v2h2V8H4z" />`,
+  random: ``,
+  recent: `<path d="m13 12.175l2.25 2.25q.275.275.275.688t-.275.712q-.3.3-.712.3t-.713-.3L11.3 13.3q-.15-.15-.225-.337T11 12.575V9q0-.425.288-.712T12 8t.713.288T13 9zm-1.713-6.462Q11 5.425 11 5V4h2v1q0 .425-.288.713T12 6t-.712-.288m7 5.576Q18.575 11 19 11h1v2h-1q-.425 0-.712-.288T18 12t.288-.712m-5.575 7Q13 18.575 13 19v1h-2v-1q0-.425.288-.712T12 18t.713.288m-7-5.575Q5.425 13 5 13H4v-2h1q.425 0 .713.288T6 12t-.288.713M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m8-10q0-3.35-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20t5.675-2.325T20 12m-8 0" />`,
+  refresh: `<path d="M16.5 20q-1.875 0-3.187-1.312T12 15.5t1.313-3.187T16.5 11t3.188 1.313T21 15.5q0 .65-.187 1.25T20.3 17.9l2 2q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-2-2q-.55.325-1.15.513T16.5 20m1.775-2.725Q19 16.55 19 15.5t-.725-1.775T16.5 13t-1.775.725T14 15.5t.725 1.775T16.5 18t1.775-.725M4 18V9v1v-4zm0 2q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v3q0 .425-.288.713T21 10h-8V6H4v12h5q.425 0 .713.288T10 19t-.288.713T9 20z" />`,
+  repeat: `<path d="m6.85 19l.85.85q.3.3.288.7t-.288.7q-.3.3-.712.313t-.713-.288L3.7 18.7q-.15-.15-.213-.325T3.426 18t.063-.375t.212-.325l2.575-2.575q.3-.3.713-.287t.712.312q.275.3.288.7t-.288.7l-.85.85H17v-3q0-.425.288-.712T18 13t.713.288T19 14v3q0 .825-.587 1.413T17 19zm10.3-12H7v3q0 .425-.288.713T6 11t-.712-.288T5 10V7q0-.825.588-1.412T7 5h10.15l-.85-.85q-.3-.3-.288-.7t.288-.7q.3-.3.712-.312t.713.287L20.3 5.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-2.575 2.575q-.3.3-.712.288T16.3 9.25q-.275-.3-.288-.7t.288-.7z" />`,
+  shuffle: `<path d="M18.97 3.72a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 1 1-1.06-1.06l.97-.97h-2.754a.75.75 0 0 0-.567.26L12.74 12l3.878 4.49a.75.75 0 0 0 .567.26h2.753l-.97-.97a.75.75 0 1 1 1.061-1.06l2.25 2.25a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 1 1-1.06-1.06l.97-.97h-2.754a2.25 2.25 0 0 1-1.702-.78l-3.734-4.322l-3.734 4.323a2.25 2.25 0 0 1-1.703.779H3.25a.75.75 0 0 1 0-1.5h3.063a.75.75 0 0 0 .568-.26L10.76 12L6.881 7.51a.75.75 0 0 0-.568-.26H3.25a.75.75 0 0 1 0-1.5h3.063a2.25 2.25 0 0 1 1.703.78l3.734 4.322l3.734-4.323a2.25 2.25 0 0 1 1.702-.779h2.753l-.97-.97a.75.75 0 0 1 0-1.06" />`,
+  'skip': `<path d="M16.5 17V7q0-.425.288-.712T17.5 6t.713.288T18.5 7v10q0 .425-.288.713T17.5 18t-.712-.288T16.5 17m-11-.875v-8.25q0-.45.3-.725t.7-.275q.125 0 .275.025t.275.125l6.2 4.15q.225.15.338.363T13.7 12t-.112.463t-.338.362l-6.2 4.15q-.125.1-.275.125t-.275.025q-.4 0-.7-.275t-.3-.725" />`,
+  'skip-end': [256,`<path fill-rule="evenodd" d="M138.033 60.858c0-14.183 8.718-16.43 13.894-10.454c5.176 5.977 47.925 60.873 52.46 68.201c4.535 7.329 5.434 13.082.086 20.28c-5.349 7.198-45.27 61.462-52.015 67.2c-6.745 5.74-14.543 3.358-14.543-10.567s.118-120.476.118-134.66m51.265 71.792c1.34-1.758 1.357-4.612.03-6.388L156.4 82.205c-1.323-1.77-2.396-1.405-2.396.792L154 175.003c0 2.207 1.088 2.568 2.424.814zM47.033 60.858c0-14.183 8.718-16.43 13.894-10.454c5.176 5.977 47.925 60.873 52.46 68.201c4.535 7.329 5.434 13.082.086 20.28c-5.349 7.198-45.27 61.462-52.015 67.2c-6.745 5.74-14.543 3.358-14.543-10.567s.118-120.476.118-134.66m51.265 71.792c1.34-1.758 1.357-4.612.03-6.388L65.4 82.205c-1.323-1.77-2.396-1.405-2.396.792L63 175.003c0 2.207 1.088 2.568 2.424.814z" />`],
+  sort: `<path d="m4.9 14.6l-.575 1.75q-.1.275-.35.463t-.55.187q-.5 0-.812-.413t-.113-.912l3-8.025q.125-.3.375-.475T6.45 7h.75q.325 0 .575.175t.375.475l3.025 8.075q.175.475-.112.875t-.788.4q-.3 0-.55-.187t-.35-.463L8.75 14.6zm.6-1.7h2.6L6.9 9.15h-.15zm10.45 2.3h4.15q.375 0 .638.263T21 16.1t-.262.638T20.1 17h-5.8q-.25 0-.425-.175T13.7 16.4v-.95q0-.175.05-.337t.175-.288L18.75 8.8H14.8q-.375 0-.638-.262T13.9 7.9t.263-.638T14.8 7h5.55q.25 0 .425.175t.175.425v.95q0 .175-.05.337t-.175.288zM9.6 5q-.175 0-.237-.15t.062-.275L11.65 2.35q.15-.15.35-.15t.35.15l2.225 2.225q.125.125.063.275T14.4 5zm2.05 16.65l-2.225-2.225q-.125-.125-.062-.275T9.6 19h4.8q.175 0 .238.15t-.063.275L12.35 21.65q-.15.15-.35.15t-.35-.15" />`,
+  stack: `<path d="M11.513 13.663q-.238-.063-.463-.188l-8.45-4.6q-.275-.15-.388-.375T2.1 8t.113-.5t.387-.375l8.45-4.6q.225-.125.463-.188T12 2.275t.488.063t.462.187l8.45 4.6q.275.15.388.375t.112.5t-.112.5t-.388.375l-8.45 4.6q-.225.125-.462.188t-.488.062t-.488-.062M12 15.725l7.85-4.275q.05-.025.475-.125q.425 0 .713.288t.287.712q0 .275-.125.5t-.4.375l-7.85 4.275q-.225.125-.462.188t-.488.062t-.488-.062t-.462-.188L3.2 13.2q-.275-.15-.4-.375t-.125-.5q0-.425.288-.712t.712-.288q.125 0 .238.038t.237.087zm0 4l7.85-4.275q.05-.025.475-.125q.425 0 .713.288t.287.712q0 .275-.125.5t-.4.375l-7.85 4.275q-.225.125-.462.188t-.488.062t-.488-.062t-.462-.188L3.2 17.2q-.275-.15-.4-.375t-.125-.5q0-.425.288-.712t.712-.288q.125 0 .238.038t.237.087z" />`,
+  musicbrainz: `<path d="M11.582 0L1.418 5.832v12.336L11.582 24V10.01L7.1 12.668v3.664c.01.111.01.225 0 .336-.103.435-.54.804-1 1.111-.802.537-1.752.509-2.166-.111-.413-.62-.141-1.631.666-2.168.384-.28.863-.399 1.334-.332V6.619c0-.154.134-.252.226-.308L11.582 3zm.836 0v6.162c.574.03 1.14.16 1.668.387a2.225 2.225 0 0 0 1.656-.717 1.02 1.02 0 1 1 1.832-.803l.004.006a1.022 1.022 0 0 1-1.295 1.197c-.34.403-.792.698-1.297.85.34.263.641.576.891.928a1.04 1.04 0 0 1 .777.125c.768.486.568 1.657-.318 1.857-.886.2-1.574-.77-1.09-1.539.02-.03.042-.06.065-.09a3.598 3.598 0 0 0-1.436-1.166 4.142 4.142 0 0 0-1.457-.369v4.01c.855.06 1.256.493 1.555.834.227.256.356.39.578.402.323.018.568.008.806 0a5.44 5.44 0 0 1 .895.022c.94-.017 1.272-.226 1.605-.446a2.533 2.533 0 0 1 1.131-.463 1.027 1.027 0 0 1 .12-.263 1.04 1.04 0 0 1 .105-.137c.023-.025.047-.044.07-.066a4.775 4.775 0 0 1 0-2.405l-.012-.01a1.02 1.02 0 1 1 .692.272h-.057a4.288 4.288 0 0 0 0 1.877h.063a1.02 1.02 0 1 1-.545 1.883l-.047-.033a1 1 0 0 1-.352-.442 1.885 1.885 0 0 0-.814.354 3.03 3.03 0 0 1-.703.365c.757.555 1.772 1.6 2.199 2.299a1.03 1.03 0 0 1 .256-.033 1.02 1.02 0 1 1-.545 1.88l-.047-.03a1.017 1.017 0 0 1-.27-1.376.72.72 0 0 1 .051-.072c-.445-.775-2.026-2.28-2.46-2.387a4.037 4.037 0 0 0-1.31-.117c-.24.008-.513.018-.866 0-.515-.027-.783-.333-1.043-.629-.26-.296-.51-.56-1.055-.611V18.5a1.877 1.877 0 0 0 .426-.135.333.333 0 0 1 .058-.027c.56-.267 1.421-.91 2.096-2.447a1.02 1.02 0 0 1-.27-1.344 1.02 1.02 0 1 1 .915 1.54 6.273 6.273 0 0 1-1.432 2.136 1.785 1.785 0 0 1 .691.306.667.667 0 0 0 .37.168 3.31 3.31 0 0 0 .888-.222 1.02 1.02 0 0 1 1.787-.79v-.005a1.02 1.02 0 0 1-.773 1.683 1.022 1.022 0 0 1-.719-.287 3.935 3.935 0 0 1-1.168.287h-.05a1.313 1.313 0 0 1-.71-.275c-.262-.177-.51-.345-1.402-.12a2.098 2.098 0 0 1-.707.2V24l10.164-5.832V5.832zm4.154 4.904a.352.352 0 0 0-.197.639l.018.01c.163.1.378.053.484-.108v-.002a.352.352 0 0 0-.303-.539zm-4.99 1.928L7.082 9.5v2l4.5-2.668zm8.385.38a.352.352 0 0 0-.295.165v.002a.35.35 0 0 0 .096.473l.013.01a.357.357 0 0 0 .487-.108.352.352 0 0 0-.301-.541zM16.09 8.647a.352.352 0 0 0-.277.163.355.355 0 0 0 .296.54c.482 0 .463-.73-.02-.703zm3.877 2.477a.352.352 0 0 0-.295.164.35.35 0 0 0 .094.475l.015.01a.357.357 0 0 0 .485-.11.352.352 0 0 0-.3-.539zm-4.375 3.594a.352.352 0 0 0-.291.172.35.35 0 0 0-.04.265.352.352 0 1 0 .33-.437zm4.375.789a.352.352 0 0 0-.295.164v.002a.352.352 0 0 0 .094.473l.015.01a.357.357 0 0 0 .485-.108.352.352 0 0 0-.3-.54zm-2.803 2.488v.002a.347.347 0 0 0-.223.084.352.352 0 0 0 .23.62.347.347 0 0 0 .23-.085.348.348 0 0 0 .12-.24.353.353 0 0 0-.35-.38.347.347 0 0 0-.007 0Z" />`,
+  'more': `<path d="M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8" />`,
+  tracks: `<path d="M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h7.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762V20q0 .825-.587 1.413T18 22zm7-14q0 .425.288.713T14 9h4l-5-5zm-2.25 11q.95 0 1.6-.65t.65-1.6V13h2q.425 0 .713-.288T16 12t-.288-.712T15 11h-2q-.425 0-.712.288T12 12v2.875q-.275-.2-.587-.288t-.663-.087q-.95 0-1.6.65t-.65 1.6t.65 1.6t1.6.65" />`,
+  trash: `<path d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z" />`,
+  volume: `<path d="M19 11.975q0-2.075-1.1-3.787t-2.95-2.563q-.375-.175-.55-.537t-.05-.738q.15-.4.538-.575t.787 0Q18.1 4.85 19.55 7.063T21 11.974t-1.45 4.913t-3.875 3.287q-.4.175-.788 0t-.537-.575q-.125-.375.05-.737t.55-.538q1.85-.85 2.95-2.562t1.1-3.788M7 15H4q-.425 0-.712-.288T3 14v-4q0-.425.288-.712T4 9h3l3.3-3.3q.475-.475 1.088-.213t.612.938v11.15q0 .675-.612.938T10.3 18.3zm9.5-3q0 1.05-.475 1.988t-1.25 1.537q-.25.15-.513.013T14 15.1V8.85q0-.3.263-.437t.512.012q.775.625 1.25 1.575t.475 2" />`,
+  'volume-low': `<path d="M9 15H6q-.425 0-.712-.288T5 14v-4q0-.425.288-.712T6 9h3l3.3-3.3q.475-.475 1.088-.213t.612.938v11.15q0 .675-.612.938T12.3 18.3zm9.5-3q0 1.05-.475 1.988t-1.25 1.537q-.25.15-.512.013T16 15.1V8.85q0-.3.263-.437t.512.012q.775.625 1.25 1.575t.475 2" />`,
+  mute: `<path d="M16.775 19.575q-.275.175-.55.325t-.575.275q-.375.175-.762 0t-.538-.575q-.15-.375.038-.737t.562-.538q.1-.05.188-.1t.187-.1L12 14.8v2.775q0 .675-.612.938T10.3 18.3L7 15H4q-.425 0-.712-.288T3 14v-4q0-.425.288-.712T4 9h2.2L2.1 4.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l17 17q.275.275.275.7t-.275.7t-.7.275t-.7-.275zm2.225-7.6q0-2.075-1.1-3.787t-2.95-2.563q-.375-.175-.55-.537t-.05-.738q.15-.4.538-.575t.787 0Q18.1 4.85 19.55 7.05T21 11.975q0 .825-.15 1.638t-.425 1.562q-.2.55-.612.688t-.763.012t-.562-.45t-.013-.75q.275-.65.4-1.312T19 11.975m-4.225-3.55Q15.6 8.95 16.05 10t.45 2v.25q0 .125-.025.25q-.05.325-.35.425t-.55-.15L14.3 11.5q-.15-.15-.225-.337T14 10.775V8.85q0-.3.263-.437t.512.012M9.75 6.95Q9.6 6.8 9.6 6.6t.15-.35l.55-.55q.475-.475 1.087-.213t.613.938V8q0 .35-.3.475t-.55-.125z" />`,
+  queue: `<path d="M16 20q-1.25 0-2.125-.875T13 17t.875-2.125T16 14q.275 0 .525.038T17 14.2V7q0-.425.288-.712T18 6h3q.425 0 .713.288T22 7t-.288.713T21 8h-2v9q0 1.25-.875 2.125T16 20M4 16q-.425 0-.712-.288T3 15t.288-.712T4 14h6q.425 0 .713.288T11 15t-.288.713T10 16zm0-4q-.425 0-.712-.288T3 11t.288-.712T4 10h10q.425 0 .713.288T15 11t-.288.713T14 12zm0-4q-.425 0-.712-.288T3 7t.288-.712T4 6h10q.425 0 .713.288T15 7t-.288.713T14 8z" />`,
+  waveform: `
+    <rect width="2.8" height="12" x="1" y="6">
+    	<animate attributeName="y" begin="SVGKWB9Ob0W.begin+0.4s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="6;1;6" />
+    	<animate attributeName="height" begin="SVGKWB9Ob0W.begin+0.4s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="12;22;12" />
+    </rect>
+    <rect width="2.8" height="12" x="5.8" y="6">
+    	<animate attributeName="y" begin="SVGKWB9Ob0W.begin+0.2s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="6;1;6" />
+    	<animate attributeName="height" begin="SVGKWB9Ob0W.begin+0.2s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="12;22;12" />
+    </rect>
+    <rect width="2.8" height="12" x="10.6" y="6">
+    	<animate id="SVGKWB9Ob0W" attributeName="y" begin="0;SVGCkSt6baQ.end-0.1s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="6;1;6" />
+    	<animate attributeName="height" begin="0;SVGCkSt6baQ.end-0.1s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="12;22;12" />
+    </rect>
+    <rect width="2.8" height="12" x="15.4" y="6">
+    	<animate attributeName="y" begin="SVGKWB9Ob0W.begin+0.2s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="6;1;6" />
+    	<animate attributeName="height" begin="SVGKWB9Ob0W.begin+0.2s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="12;22;12" />
+    </rect>
+    <rect width="2.8" height="12" x="20.2" y="6">
+    	<animate id="SVGCkSt6baQ" attributeName="y" begin="SVGKWB9Ob0W.begin+0.4s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="6;1;6" />
+    	<animate attributeName="height" begin="SVGKWB9Ob0W.begin+0.4s" calcMode="spline" dur="0.6s" keySplines=".14,.73,.34,1;.65,.26,.82,.45" values="12;22;12" />
+    </rect>
+  `,
+}
diff --git a/src/components/InfiniteLoader.vine.ts b/src/components/InfiniteLoader.vine.ts
@@ -0,0 +1,36 @@
+import { useTemplateRef, watch, onMounted, onBeforeUnmount } from 'vue'
+import { isElementInViewport } from '../utils'
+
+export const InfiniteLoader = ({ isLoading, hasMore }: { isLoading: boolean, hasMore: boolean }) => {
+  const emit = vineEmits(['load-more'])
+  const
+    loaderElement = useTemplateRef('loader'),
+    loaderObserver = new IntersectionObserver(([ entry ]) => {
+      (
+        entry?.isIntersecting &&
+        !isLoading && hasMore
+      ) && emit('load-more')
+    })
+
+  onBeforeUnmount(() => loaderObserver.unobserve(loaderElement.value as HTMLElement))
+  onMounted(() => {
+    loaderObserver.observe(loaderElement.value as HTMLElement)
+    emit('load-more')
+  })
+  watch(
+    () => [ isLoading, hasMore ],
+    () => {
+      (
+        isElementInViewport(loaderElement.value as HTMLElement) &&
+        !isLoading && hasMore
+      ) && emit('load-more')
+    },
+    { immediate: true }
+  )
+
+  return vine`
+    <div ref="loader" class="row justify-content-center">
+      <span :aria-busy="isLoading" />
+    </div>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/Logo.vine.ts b/src/components/Logo.vine.ts
@@ -0,0 +1,42 @@
+export const Logo = () => {
+  vineStyle.scoped(`
+    div {
+      display: flex !important;
+      align-items: flex-end;
+      margin-left: .5rem;
+    }
+
+    span {
+      white-space: nowrap;
+      margin-left: .5rem;
+    }
+
+    svg {
+      fill: var(--accent-hex);
+      height: 32px;
+      margin-bottom: 2px;
+    }
+  `)
+
+  return vine`
+    <div>
+      <svg xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="5.74 31.24 123.89 72.89">
+        <g transform="matrix(1.0344 0 0 1.0869 -2.068 -181.521)">
+          <rect width="5.994" height="23.366" x="9.929" y="224.55" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="41.989" x="19.849" y="215.2" rx="2.997" ry="2.819" />
+          <rect width="5.994" height="49.213" x="29.768" y="211.49" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="58.69" x="39.688" y="202.01" rx="2.997" ry="3.038" />
+          <rect width="5.994" height="62.402" x="49.607" y="198.3" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="62.733" x="59.526" y="197.97" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="58.889" x="69.446" y="201.81" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="49.412" x="79.365" y="211.29" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="48.948" x="89.285" y="211.75" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="44.176" x="99.204" y="216.53" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="36.886" x="109.12" y="223.55" rx="2.997" ry="2.997" />
+          <rect width="5.994" height="22.372" x="119.04" y="230.78" rx="2.997" ry="2.997" />
+        </g>
+      </svg>
+      <span>music.zaphyra.eu</span>
+    </div>
+  `
+}
diff --git a/src/components/Navbars.vine.ts b/src/components/Navbars.vine.ts
@@ -0,0 +1,438 @@
+import { computed, watch, useTemplateRef, shallowRef, inject, getCurrentInstance, onMounted, onBeforeUnmount } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+
+import { isMobile } from '../utils'
+import { useSubsonicApi } from '../subsonicApi'
+import { useMainStore } from '../store/main'
+import { usePlaylistStore } from '../store/playlist'
+import { useCacheStore } from '../store/cache'
+
+import { pushReload } from '../reload'
+import { sleep } from '../utils'
+
+import { 
+  type ConfirmDialogExpose,
+  ConfirmDialog
+} from '../components/ConfirmDialog.vine'
+import { AboutDialog } from '../view/About.vine'
+
+
+import { Logo } from './Logo.vine'
+import { EditPlaylistModal } from './EditPlaylistModal.vine'
+
+const
+  qualityOptions = [
+    { text: 'OPUS 128k', value: { format: 'opus', bitrate: 128 } },
+    { text: 'OPUS 160k', value: { format: 'opus', bitrate: 160 } },
+    { text: 'Original',  value: { format: 'raw',  bitrate: 0 } },
+  ],
+  themeColors = [
+    { name: 'Blue', value: '#178994' },
+    { name: 'Green', value: '#1db954' },
+    { name: 'Orange', value: '#ff8c00' },
+    { name: 'Purple', value: '#6f42c1' },
+    { name: 'Red', value: '#bf0000' }
+  ],
+  menuItems = [
+    {
+      name: 'discover',
+      title: 'Discover',
+      icon: 'discover',
+      to: { name: 'discover' },
+    },
+    {
+      name: 'queue',
+      title: 'Player Queue',
+      icon: 'queue',
+      to: { name: 'queue' },
+    },
+    {
+      isDivider: true,
+      title: 'Library',
+    },
+    {
+      name: 'menu',
+      title: 'Menu',
+      icon: 'more',
+      toggleMenu: true,
+      hideSidebar: true,
+    },
+    {
+      name: 'albums',
+      title: 'Albums',
+      icon: 'albums',
+      to: { name: 'albums' },
+      hideNavBar: true,
+    },
+    {
+      name: 'artists',
+      title: 'Artists',
+      icon: 'artists',
+      to: { name: 'artists' },
+      hideNavBar: true,
+    },
+    {
+      name: 'geners',
+      title: 'Genres',
+      icon: 'genres',
+      to: { name: 'genres' },
+      hideNavBar: true,
+    },
+    {
+      name: 'playlists',
+      title: 'Playlists',
+      icon: 'playlist',
+      to: { name: 'playlists' },
+    },
+    {
+      name: 'favourites',
+      title: 'Favourites',
+      icon: 'heart-fill',
+      to: { name: 'favourites', params: { section: 'tracks'}},
+    },
+  ]
+
+export const NavSidebar = () => {
+  const
+    router = useRouter(),
+    route = useRoute(),
+
+    subsonicApi = useSubsonicApi(),
+    mainStore = useMainStore(),
+    playlistStore = usePlaylistStore(),
+    cacheStore = useCacheStore(),
+
+    isScanning = shallowRef(false),
+    showAbout = shallowRef(false),
+    showModal = shallowRef(false),
+    cacheSize = shallowRef(0),
+
+    confirmDialog = shallowRef<ConfirmDialogExpose | null>(null),
+
+    playlists = computed(() => playlistStore.playlists?.slice(0, 10)),
+
+    go = (item: any) => {
+      if (item.toggleMenu) {
+        mainStore.toggleMenu()
+      } else {
+        router.push(item.to)
+        mainStore.hideMenu()        
+      }
+    },
+
+    isActive = (item: any) => (
+      (item.match)
+      ? item.match(route)
+      : (
+        (!item.toggleMenu)
+        ? !mainStore.menuVisible && (route.name === item.to.name)
+        : mainStore.menuVisible
+      )
+    ),
+
+    openModal = () => {
+      showModal.value = true
+    },
+    closeModal = () => {
+      showModal.value = false
+    },
+
+    isActiveStreamQuality = (option) => {
+      if (option.format !== mainStore.streamFormat)
+        return false
+
+      if (option.bitrate !== mainStore.streamBitrate)
+        return false
+
+      return true
+    },
+
+    setStreamQuality = (option) => {
+      mainStore.setStreamFormat(option.format)
+      mainStore.setStreamBitrate(option.bitrate)
+      subsonicApi.setStreamFormat(option.format, option.bitrate)
+    },
+
+    createPlaylist = (name: string) => {
+      playlistStore.create(name)
+      closeModal()
+    },
+
+    onDrop = async (playlistId: string, event: any) => {
+      event.preventDefault()
+
+      if (event.dataTransfer?.types.includes('application/x-track-id')) 
+        return playlistStore.addTracks(playlistId, [ event.dataTransfer.getData('application/x-track-id') ])
+
+      if (event.dataTransfer?.types.includes('application/x-album-id')) {
+        const album = await subsonicApi.getAlbumDetails(event.dataTransfer.getData('application/x-album-id'))
+
+        return playlistStore.addTracks(
+          playlistId,
+          album.tracks!.map(item => item.id)
+        )
+      }
+    },
+
+    onDragover = (event: DragEvent) => {
+      const validTypes = [ 'application/x-track-id', 'application/x-album-id' ]
+
+      if (!event.dataTransfer?.types.some(i => validTypes.includes(i)))
+        return 
+
+      event.dataTransfer.dropEffect = 'copy'
+      event.preventDefault()
+    },
+
+    setTheme = (color: string) => {
+      mainStore.setThemeColor(color)
+      mainStore.applyThemeColor()
+    },
+
+    updateCacheSize = async () => {
+      cacheSize.value = await cacheStore.getCacheSizeGB()
+    },
+
+    scan = async () => {
+      if (isScanning.value) return
+
+      mainStore.showLoader()
+      isScanning.value = true
+
+      try {
+        let scanning = false
+        await subsonicApi.scan()
+
+        do {
+          await sleep(1000)
+          scanning = await subsonicApi.getScanStatus()
+        } while (scanning)
+
+        pushReload()
+
+        router.replace({
+          name: route.name as string,
+          params: { ...(route.params || {}) },
+          query: { ...(route.query || {}), t: Date.now().toString() },
+        })
+      } finally {
+        mainStore.hideLoader()
+        isScanning.value = false
+      }
+    },
+
+    clearCache = async () => {
+      if (!confirmDialog.value) return
+
+      const userConfirmed = await confirmDialog.value.open(
+        'Clear cache',
+        'Do you really want to clear cached content?'
+      )
+
+      if (!userConfirmed) return
+
+      try {
+        mainStore.showLoader()
+
+        const success = await cacheStore.clearAllAudioCache()
+
+        if (success) {
+          window.dispatchEvent(new CustomEvent('audioCacheClearedAll'))
+          router.replace({
+            name: route.name as string,
+            params: { ...(route.params || {}) },
+            query: { ...(route.query || {}), t: Date.now().toString() },
+          })
+        }
+
+        await updateCacheSize()
+        mainStore.hideLoader()
+      } catch (err) {
+        console.error('Error clearing cache:', err)
+      }
+    },
+
+    logout = async () => {
+      if (!confirmDialog.value) return
+
+      const userConfirmed = await confirmDialog.value.open(
+        'Logout',
+        'You are about to end the session. Continue?'
+      )
+
+      if (userConfirmed) {
+        mainStore.serverCredentials = null
+        mainStore.serverInfo = null
+        router.go(0)
+      }
+    }
+
+  onMounted(() => {
+    updateCacheSize()
+    window.addEventListener('audioCached', updateCacheSize)
+    window.addEventListener('audioCacheDeleted', updateCacheSize)
+    window.addEventListener('audioCacheClearedAll', updateCacheSize)
+  })
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('audioCached', updateCacheSize)
+    window.removeEventListener('audioCacheDeleted', updateCacheSize)
+    window.removeEventListener('audioCacheClearedAll', updateCacheSize)
+  })
+
+  return vine`
+    <nav class="sidebar" :class="{ 'open': mainStore.menuVisible }">
+      <div class="logo">
+        <Logo />
+      </div>
+
+      <template
+        v-for="item in menuItems"
+        :key="'sidebar_'+item.name"
+      >
+        <div v-if="item?.isDivider" class="divider">
+          <span>{{ item.title }}</span>
+        </div>
+        <router-link
+          v-else
+          v-if="!item?.hideSidebar"
+          :to="item.to"
+          :class="{ active: isActive(item) }"
+          @click="mainStore.hideMenu"
+        >
+          <Icon :icon="item.icon" />{{ item.title }}
+        </router-link>
+      </template>
+
+      <div class="divider">
+        <span>Playlists</span>
+        <button @click="openModal">
+          <Icon icon="add" />
+        </button>
+      </div>
+
+      <router-link
+        :to="{name: 'playlist', params: { id: 'random' }}"
+        @click="mainStore.hideMenu"
+      >
+        <Icon icon="playlist" /> Random
+      </router-link>
+
+      <router-link
+        v-for="item in playlists"
+        :key="item.id"
+        :to="{ name: 'playlist', params: { id: item.id }}"
+        @click="mainStore.hideMenu"
+        @dragover="onDragover"
+        @drop="onDrop(item.id, $event)"
+      >
+        <Icon icon="playlist" /> {{ item.name }}
+      </router-link>
+
+      <Dropdown direction="up">
+        <template #button>
+          <Icon icon="more" /> More
+        </template>
+
+        <DropdownItem class="themes">
+          <button
+            v-for="color in themeColors"
+            :key="color.value"
+            :style="{ backgroundColor: color.value, border: mainStore.themeColor === color.value ? '2px solid white' : '1px solid #ccc' }"
+            :data-tooltip="color.name"
+            @click="setTheme(color.value)"
+          />
+        </DropdownItem>
+        <DropdownItem divider />
+        <DropdownItem class="small color-muted">
+          Stream Quality
+        </DropdownItem>
+        <DropdownItem
+          v-for="option in qualityOptions"
+          :key="option.value.format+option.value.bitrate"
+          :class="isActiveStreamQuality(option.value) ? 'active' : ''"
+          @click="setStreamQuality(option.value)"
+        >{{ option.text }}
+        </DropdownItem>
+        <DropdownItem divider/>
+        <DropdownItem icon="trash" @click="clearCache">
+          <span>
+            Clear Cache
+            <span class="small color-muted">
+              ({{ cacheSize }} GB)
+            </span>
+          </span>
+        </DropdownItem>
+        <DropdownItem icon="refresh" @click="scan">
+          Scan Library
+        </DropdownItem>
+        <DropdownItem href="/app/" target="_blank" icon="link">
+          Navidrome UI
+        </DropdownItem>
+        <DropdownItem icon="info" @click="showAbout = true">
+          About
+        </DropdownItem>
+        <DropdownItem icon="logout" @click="logout">
+          Log out
+        </DropdownItem>
+      </Dropdown>
+    </nav>
+
+    <Teleport to="#dialogBoxes">
+      <AboutDialog :visible="showAbout" @close="showAbout = false" />
+      <ConfirmDialog ref="confirmDialog" />
+      <EditPlaylistModal
+        @close="closeModal"
+        v-if="showModal"
+        mode="create"
+        @create-playlist="createPlaylist"
+      />
+    </Teleport>
+  `
+}
+
+export const NavBar = () => {
+  const
+    router = useRouter(),
+    route = useRoute(),
+
+    mainStore = useMainStore(),
+
+    go = (item: any) => {
+      if (item.toggleMenu) {
+        mainStore.toggleMenu()
+      } else {
+        router.push(item.to)
+        mainStore.hideMenu()        
+      }
+    },
+
+    isActive = (item: any) => (
+      (item.match)
+      ? item.match(route)
+      : (
+        (item.toggleMenu)
+        ? mainStore.menuVisible
+        : !mainStore.menuVisible && (route.name === item?.to?.name)
+      )
+    )
+
+  return vine`
+    <nav class="navbar">
+      <template 
+        v-for="item in menuItems"
+        :key="'nav_'+item.name"
+      >
+        <button
+          v-if="!item?.isDivider && !item?.hideNavBar"
+          :class="{ active: isActive(item) }"
+          :data-tooltip="item.title"
+          type="button"
+          @click="go(item)"
+        >
+          <Icon :icon="item.icon" />
+        </button>
+      </template>
+    </nav>
+  `;
+}
diff --git a/src/components/OverflowMenu.vine.ts b/src/components/OverflowMenu.vine.ts
@@ -0,0 +1,13 @@
+export const OverflowMenu = () => {
+  const disabled = vineProp.withDefault(false)
+  const toggleClass = vineProp.withDefault('')
+
+  return vine`
+    <Dropdown :disabled="disabled" :toggle-class="toggleClass">
+      <template #button>
+        <Icon icon="more" />
+      </template>
+      <slot />
+    </Dropdown>
+  `;
+}+
\ No newline at end of file
diff --git a/src/components/Player.vine.ts b/src/components/Player.vine.ts
@@ -0,0 +1,227 @@
+import { watch, ref, computed } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+
+import { ReplayGainMode } from '../types'
+import { useMainStore } from '../store/main'
+import { usePlayerStore } from '../store/player'
+import { useFavouriteStore } from '../store/favourite'
+import { formatDuration, isMobile } from '../utils'
+
+export const Player = () => {
+  const
+    router = useRouter(),
+    route = useRoute(),
+
+    mainStore = useMainStore(),
+    playerStore = usePlayerStore(),
+    favouriteStore = useFavouriteStore(),
+
+    playState = ref(0),
+
+    track = computed(() => playerStore.track),
+    isRepeat = computed<boolean>(() => playerStore.repeat),
+    isPlaying = computed<boolean>(() => playerStore.isPlaying),
+    isMuted = computed<boolean>(() => (playerStore.volume <= 0)),
+    isShuffle = computed<boolean>(() => playerStore.shuffle),
+    isFavourite = computed<boolean>(() => (
+      !!track.value && favouriteStore.get('track', track.value.id)
+    )),
+
+    replayGainMode = computed<ReplayGainMode>(() => playerStore.replayGainMode),
+
+    documentTitle = computed<string>(() => ([
+      track.value?.title,
+      track.value?.artists?.map(a => a.name).join(', ') || track.value?.album,
+      import.meta.env.VITE_APP_NAME,
+    ].filter(Boolean).join(' • '))),
+
+    onBackgroundClick = (e: MouseEvent) => (
+      (isMobile())
+      ? playerStore.playPause()
+      : (
+        (route.name !== 'queue')
+        ? router.push({ name: 'queue' })
+        : router.back()
+      )
+    ),
+
+    onAlbumClick = () => {
+      const t = playerStore.track
+      if (!t?.albumId) return
+
+      (route.name === 'album' && String(route.params.id) === String(t.albumId))
+      ? router.back()
+      : router.push({ name: 'album', params: { id: t.albumId } })
+    },
+
+    formatter = (value: number) => (
+      `${formatDuration(value)} / ${formatDuration(playerStore.duration)}`
+    ),
+
+
+    playPause = () => playerStore.playPause(),
+    back = () => playerStore.back(),
+    next = () => playerStore.next(true),
+    toggleReplayGain = () => playerStore.toggleReplayGain(),
+    toggleRepeat = () => playerStore.toggleRepeat(),
+    toggleShuffle = () => playerStore.toggleShuffle(),
+    toggleFavourite = () => (
+      (track.value) && favouriteStore.toggle('track', track.value.id)
+    )
+
+  watch(
+    () => playerStore.currentTime,
+    currentTime => {
+      playState.value = currentTime
+    },
+    { immediate: true }
+  )
+
+  watch(
+    documentTitle,
+    (value) => {
+      document.title = value
+    },
+    { immediate: true }
+  )
+
+  vineStyle.import.scoped('../style/player.css')
+
+  return vine`
+    <div class="player" :class="{ blur: mainStore.menuVisible, visible: track }" @click.stop="onBackgroundClick">
+      <input
+        type="range"
+        class="playback-slider"
+        v-model="playState"
+        :min="0"
+        :max="playerStore.duration"
+        :step="0.1"
+        @click.stop
+        @change="event => playerStore.seek(event.target.value)"
+      />
+      <div class="background">
+        <!-- track info -->
+        <div v-if="track" class="track-info">
+          <img class="cover" :src="track.image" @click.stop="onAlbumClick">
+          <div>
+            <router-link @click.stop :to="{ name: 'album', params: { id: track.albumId } }">
+              {{ track.title }}
+            </router-link>
+            <div class="color-muted">
+              <template
+                v-if="track.artists.length"
+                v-for="(artist, index) in track.artists"  
+                :key="artist.id"
+              >
+                  <span v-if="index > 0">, </span>
+                  <router-link @click.stop :to="{ name: 'artist', params: { id: artist.id } }">
+                    {{ artist.name }}
+                  </router-link>
+              </template>
+              <template v-else-if="track.album">
+                {{ track.album }}
+              </template>
+            </div>
+          </div>
+          <button class="hide-mobile" @click.stop="toggleFavourite" data-tooltip="Favourite">
+            <Icon :icon="isFavourite ? 'heart-fill' : 'heart'" />
+          </button>
+        </div>
+
+        <!-- transport -->
+        <div class="transport">
+          <button class="hide-mobile" @click.stop="toggleShuffle" data-tooltip="Shuffle">
+            <Icon icon="shuffle" :class="{ 'accent-color': isShuffle }" />
+          </button>
+          <button class="previous" @click.stop="back" data-tooltip="Previous Track">
+            <Icon icon="skip" />
+          </button>
+          <button class="play" @click.stop="playPause" :data-tooltip="isPlaying ? 'Pause' : 'Play'">
+            <Icon :icon="isPlaying ? 'pause' : 'play'" />
+          </button>
+          <button class="next" @click.stop="next" data-tooltip="Next Track">
+            <Icon icon="skip" />
+          </button>
+          <button class="hide-mobile" :class="{ 'accent-color': isRepeat }" @click.stop="toggleRepeat" data-tooltip="Repeat">
+            <Icon icon="repeat" />
+          </button>
+        </div>
+
+        <!-- right controls -->
+        <div>
+          <button
+            v-if="track && track.replayGain"
+            data-tooltip="ReplayGain"
+            class="hide-mobile"
+            :class="{ 'accent-color': replayGainMode !== ReplayGainMode.None }"
+            @click.stop="toggleReplayGain"
+          >
+            <Icon icon="replaygain" :mode="replayGainMode" />
+          </button>
+
+          <Dropdown direction="up" @click.stop>
+            <template #button>
+              <Icon :icon="(!isMuted) ? 'volume' : 'mute'" class="hide-mobile" />
+              <Icon icon="more" class="hide-desktop" />
+            </template>
+
+            <DropdownItem>
+              <input type="range"
+                class="volume-slider"
+                v-model="playerStore.volume"
+                orientation="vertical"
+                direction="rtl"
+                min="0.0"
+                max="1.0"
+                step="0.01"
+                @change="event => playerStore.setVolume(event.target.value)"
+              />
+            </DropdownItem>
+            <DropdownItem
+              role="button"
+              class="hide-desktop"
+              @click.stop="toggleFavourite"
+            >
+              <Icon :icon="isFavourite ? 'heart-fill' : 'heart'" class="margin-auto" />
+            </DropdownItem>
+
+            <DropdownItem
+              v-if="track && track.replayGain"
+              role="button"
+              data-tooltip="ReplayGain"
+              class="hide-desktop"
+              :class="{ 'accent-color': replayGainMode !== ReplayGainMode.None }"
+              @click.stop="toggleReplayGain"
+            >
+              <Icon icon="replaygain" :mode="replayGainMode" />
+            </DropdownItem>
+            <DropdownItem
+              role="button"
+              class="hide-desktop"
+              data-tooltip="Shuffle"
+              @click.stop="toggleShuffle"
+            >
+              <Icon icon="shuffle" :class="{ 'accent-color': isShuffle }" />
+            </DropdownItem>
+            <DropdownItem
+              role="button"
+              class="hide-desktop"
+              data-tooltip="Repeat"
+              @click.stop="toggleRepeat"
+            >
+              <Icon icon="repeat" :class="{ 'accent-color': isRepeat }" />
+            </DropdownItem>
+            <DropdownItem
+              role="button"
+              class="hide-desktop"
+              data-tooltip="Favourite"
+              @click.stop="toggleFavourite"
+            >
+              <Icon :icon="isFavourite ? 'heart-fill' : 'heart'" />
+            </DropdownItem>
+          </Dropdown>
+        </div>
+      </div>
+    </div>
+  `
+}
diff --git a/src/components/PlaylistList.vine.ts b/src/components/PlaylistList.vine.ts
@@ -0,0 +1,64 @@
+import { computed } from 'vue'
+
+import type { Playlist } from '../types'
+import { useSubsonicApi } from '../subsonicApi'
+import { usePlayerStore } from '../store/player'
+
+export const PlaylistList = ({
+  items,
+  allowHScroll = false,
+  isPlaylistView = false,
+  tileSize = 200,
+}: {
+  items: Playlist[],
+  allowHScroll?: boolean,
+  isPlaylistView?: boolean,
+  tileSize?: number,
+}) => {
+  const
+    api = useSubsonicApi(),
+    playerStore = usePlayerStore(),
+    playNow = async(id: string) => {
+      playerStore.setShuffle(false)
+      const playlist = await api.getPlaylist(id)
+      return playerStore.playTrackList(playlist.tracks!)
+    }
+
+  return vine`
+    <Tiles :tile-size="tileSize" :allow-h-scroll="allowHScroll">
+      <Tile
+        v-for="(item, index) in items"
+        :key="item.id || index"
+        :to="{ name: 'playlist', params: { id: item.id } }"
+        :title="item.name || 'Untitled Playlist'"
+        :image="item.image || ''"
+      >
+        <template #title>
+          <router-link :to="{ name: 'playlist', params: { id: item.id } }">
+            {{ item.name }}
+          </router-link>
+        </template>
+
+        <!-- Tracks info -->
+        <template #text>
+          <strong>{{ item.trackCount || 0 }}</strong> tracks
+        </template>
+
+        <!-- Context Menu -->
+        <template #context-menu>
+          <DropdownItem icon="play" @click="playNow(item.id)">
+            Play
+          </DropdownItem>
+
+          <DropdownItem v-if="isPlaylistView" icon="edit" @click="$emit('edit-playlist', item)">
+            Edit
+          </DropdownItem>
+
+          <DropdownItem v-if="isPlaylistView" icon="trash" @click.stop.prevent="$emit('remove-playlist', item.id)">
+            Remove
+          </DropdownItem>
+        </template>
+      </Tile>
+    </Tiles>
+  `
+}
diff --git a/src/components/SearchInput.vine.ts b/src/components/SearchInput.vine.ts
@@ -0,0 +1,86 @@
+import { useTemplateRef, ref, onBeforeUnmount } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { onKeyStroke, onStartTyping, watchDebounced } from '@vueuse/core'
+
+import { useMainStore } from '../store/main'
+
+export const SearchInput = () => {
+  const
+    router = useRouter(),
+    route = useRoute(),
+
+    mainStore = useMainStore(),
+
+    searchQuery = ref(route.query.q),
+    searchInput = useTemplateRef<HTMLElement | null>('searchInput'),
+
+    cancelListenerEsc = onKeyStroke(
+      'Escape',
+      (event) => {
+        if (!searchInput.value) return
+        event.preventDefault();
+
+        if (searchInput.value !== document.activeElement) {
+          if (route.name !== 'search') return;
+          searchQuery.value = ''
+          router.back()
+        } else {
+          searchInput.value.blur()
+        }
+      }
+    ),
+    cancelListenerCtrlK = onKeyStroke(
+      e => e.key === 'k' && e.ctrlKey,
+      (event) => {
+        if (!searchInput.value) return
+
+        event.preventDefault()
+        searchInput.value.focus()
+      }
+    )
+
+  onBeforeUnmount(() => {
+    cancelListenerEsc()
+    cancelListenerCtrlK()
+  })
+
+  onStartTyping(
+    () => {
+      if (!searchInput.value) return
+
+      if (searchInput.value !== document.activeElement) {
+        searchQuery.value = ''
+        searchInput.value.focus()
+      }
+    }
+  )
+
+  watchDebounced(
+    searchQuery,
+    () => {
+      const queryValue = searchQuery.value
+
+      if (queryValue !== '') {
+        router.push({
+          name: 'search',
+          replace: route.name === 'search',
+          query: { q: queryValue },
+        })
+      } else {
+        if (route.name !== 'search') return;
+        router.back()
+      }
+    },
+    { debounce: 500, maxWait: 1000 },
+  )
+
+  return vine`
+    <div class="search">
+      <button :class="{ invisible: !$route.meta.backButton }" @click="router.back()">
+        <Icon icon="back" />
+      </button>
+      <input type="search" placeholder="Search" ref="searchInput" v-model.trim="searchQuery">
+      <button :class="{ invisible: !mainStore.isLoading }" aria-busy="true" />
+    </div>
+  `
+}
diff --git a/src/components/SwitchInput.vine.ts b/src/components/SwitchInput.vine.ts
@@ -0,0 +1,18 @@
+import { computed } from 'vue'
+import { uniqueId } from 'lodash-es'
+
+export const SwitchInput = ({ modelValue }: { modelValue: boolean }) => {
+  vineEmits([ 'input', 'update:model-value' ])
+
+  const id = computed(() => uniqueId('switch-'))
+
+  return vine`
+    <input
+      type="checkbox"
+      role="switch"
+      :id="id"
+      :checked="modelValue"
+      @change="$emit('update:model-value', $event.target.checked)"
+    >
+  `
+}+
\ No newline at end of file
diff --git a/src/components/Tiles.vine.ts b/src/components/Tiles.vine.ts
@@ -0,0 +1,56 @@
+import { computed } from 'vue'
+import fallbackImage from '../assets/fallback.svg';
+
+export const Tiles = (props: {
+  allowHScroll?: boolean,
+  tileSize?: number,
+  twoRows?: boolean,
+}) => {
+  const
+    classes = computed(() => ({
+      'scroll': props.allowHScroll ?? false,
+      'two-rows': props.twoRows ?? false,
+    })),
+    styles = computed(() => ({
+      '--tile-size': (props.tileSize ?? 150)+'px',
+      '--tile-size-mobile': (Math.round((props.tileSize ?? 150) * 0.85))+'px',
+    }))
+
+  return vine`
+    <div class="tiles" :class="classes" :style="styles">
+      <slot />
+    </div>
+  `
+}
+
+export const Tile = ({
+  to, title, text, image = fallbackImage
+}: {
+  to?: object,
+  title?: string,
+  text?: string,
+  image?: string,
+}) => {
+  return vine`
+    <div class="tile">
+      <div class="image">
+        <component :is="to ? 'router-link' : 'template'" :to="to ? to : undefined">
+          <img :src="image" loading="lazy">
+        </component>
+      </div>
+      <div class="body text-truncate">
+        <div class="title text-truncate">
+          <slot name="title">
+            <component :is="to ? 'router-link' : 'span'" :to="to ? to : undefined">{{ title }}</component>
+          </slot>
+        </div>
+        <slot name="text">{{ text }}</slot>
+        <ContextMenu :enabled="!!$slots['context-menu']">
+          <template #context-menu>
+            <slot name="context-menu" />
+          </template>
+        </ContextMenu>
+      </div>
+    </div>
+  `
+}+
\ No newline at end of file
diff --git a/src/components/TrackList.vine.ts b/src/components/TrackList.vine.ts
@@ -0,0 +1,268 @@
+import { ref, computed, onMounted, onUnmounted, watchEffect } from 'vue'
+
+import type { Track } from '../types'
+
+import { formatDuration, formatTitle } from '../utils'
+import { useSubsonicApi } from '../subsonicApi'
+
+import { usePlayerStore } from '../store/player'
+import { useFavouriteStore } from '../store/favourite'
+import { usePlaylistStore } from '../store/playlist'
+import { useCacheStore } from '../store/cache'
+
+export const TrackList = ({
+  tracks,
+  isPlaylistView = false,
+  hideAlbum = false,
+  hideArtist = false,
+  hideDuration = false,
+  hideCover = false,
+  activeBy = 'id',
+}: {
+  tracks: Track[],
+  isPlaylistView?: boolean,
+  hideAlbum?: boolean,
+  hideArtist?: boolean,
+  hideCover?: boolean,
+  hideDuration?: boolean,
+  activeBy?: 'id' | 'index',
+}) => {
+  vineEmits(['remove-track'])
+
+  const
+    subsonicApi = useSubsonicApi(),
+    playerStore = usePlayerStore(),
+    favouriteStore = useFavouriteStore(),
+    playlistStore = usePlaylistStore(),
+    cacheStore = useCacheStore(),
+
+    playlistSelect = ref<number | null>(null),
+    isCached = ref<boolean[]>(tracks.map(track => false)),
+
+    isFavourite = computed<boolean[]>(() => tracks.map(track => !!favouriteStore.tracks[track.id])),
+    isPlaying = computed(() => playerStore.isPlaying),
+    playingTrackId = computed(() => playerStore.trackId),
+    queueIndex = computed(() => playerStore.queueIndex),
+
+    toggleFavourite = (index: number) => {
+      if (!tracks[index]?.id)
+        return
+
+      favouriteStore.toggle('track', tracks[index].id)
+    },
+
+    setNextInQueue = (index: number) => {
+      if (!tracks[index])
+        return
+
+      playerStore.setNextInQueue([ tracks[index] ])
+    },
+
+    addToQueue = (index: number) => {
+      if(!tracks[index])
+        return
+
+      playerStore.addToQueue([ tracks[index] ])
+    },
+
+    addToPlaylist = (playlistId: string) => {
+      const index = playlistSelect.value
+
+      if (!index || !tracks[index])
+        return
+
+      playlistStore.addTracks(playlistId, [ tracks[index].id ])
+      playlistSelect.value = null
+    },
+
+    download = (index: number) => {
+      if (!tracks[index])
+        return
+
+      if (subsonicApi)
+        window.location.href = subsonicApi.getDownloadUrl(tracks[index]?.id)
+    },
+
+    isActive = (item: Track, index: number) => (
+      (activeBy !== 'index')
+      ? item.id === playerStore.trackId
+      : index === playerStore.queueIndex
+    ),
+
+    handlePlay = (index: number) => {
+      if (!tracks[index])
+        return
+
+      playerStore.setShuffle(false)
+
+      if (tracks[index].id === playerStore.trackId)
+        return playerStore.playPause()
+
+      return playerStore.playTrackList(tracks, index)
+    },
+
+    cacheHandler = (index: number, e: Event) => {
+      const cachedUrl = (e as CustomEvent).detail
+      if (cachedUrl === tracks[index]?.url)
+        isCached.value[index] = true
+    },
+
+    clearHandler = async (index: number) => {
+      isCached.value[index] = (!tracks[index]?.url) ? false : (await cacheStore.hasTrack(tracks[index]?.url))
+    },
+
+    dragstart = (index, event: DragEvent) => {
+      if (!tracks[index])
+        return
+
+      if (!tracks[index].isStream)
+        event.dataTransfer?.setData('application/x-track-id', tracks[index].id)
+    }
+
+  onMounted(() => {
+    tracks.forEach((track, index) => {
+      window.addEventListener('audioCached', event => cacheHandler(index, event))
+      window.addEventListener('audioCacheClearedAll', event => clearHandler(index))
+    })
+  })
+
+  onUnmounted(() => {
+    tracks.forEach((track, index) => {
+      window.removeEventListener('audioCached', event => cacheHandler(index, event))
+      window.removeEventListener('audioCacheClearedAll', event => clearHandler(index))
+    })
+  })
+
+  watchEffect(async () => {
+    tracks.forEach(async (track, index) => {
+      if (!track.url)
+        return (isCached.value[index] = false)
+
+      isCached.value[index] = await cacheStore.hasTrack(track.url)
+    })
+  })
+
+  return vine`
+    <div class="scroll">
+      <table class="numbered">
+        <thead>
+          <tr>
+            <th>#</th>
+            <th>
+              Title
+            </th>
+            <th v-if="!hideArtist" class="hide-mobile">
+              Artist
+            </th>
+            <th v-if="!hideAlbum" class="hide-mobile hide-tablet">
+              Album
+            </th>
+            <th v-if="!hideDuration" class="text-end">
+              Time
+            </th>
+            <th class="text-end">
+              Actions
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr
+            v-for="(track, index) in tracks"
+            :key="track.id || index"
+            :class="{ active: isActive(track, index) }"
+            draggable="true"
+            @dragstart="dragstart(track, $event)"
+            @click="handlePlay(index)"
+          >
+            <td>
+              <Icon class="icon" :icon="(isActive(track, index) && isPlaying) ? 'waveform' : 'play'" />
+              <span class="number">{{ (index + 1) ?? '-' }}</span>
+            </td>
+
+            <td class="text-truncate">
+              <img
+                v-if="!hideCover && track.image"
+                :src="track.image"
+                loading="lazy"
+                class="cover"
+              >
+              <slot>{{ formatTitle(track.title) }}</slot>
+            </td>
+
+            <td v-if="!hideArtist" class="hide-mobile text-truncate">
+              <span v-for="(artist, index) in track.artists" :key="artist.id">
+                <span v-if="index > 0">, </span>
+                <router-link
+                  v-if="artist.id"
+                  :to="{ name: 'artist', params: { id: artist.id } }"
+                  @click.stop
+                >
+                  {{ artist.name }}
+                </router-link>
+                <span v-else>
+                  {{ artist.name }}
+                </span>
+              </span>
+            </td>
+
+            <td v-if="!hideAlbum" class="hide-mobile hide-tablet text-truncate">
+              <template v-if="track.albumId">
+                <router-link :to="{ name: 'album', params: { id: track.albumId } }" @click.stop>
+                  {{ track.album }}
+                </router-link>
+              </template>
+              <template v-else>
+                {{ track.album }}
+              </template>
+            </td>
+
+            <td v-if="!hideDuration" class="w-fit-content text-end">
+              {{ formatDuration(track.duration ?? 0) }}
+            </td>
+
+            <td class="row align-items-center justify-content-space-between" @click.stop>
+              <Icon
+                style="margin-left:.25rem"
+                :class="!isCached[index] ? 'invisible' : ''"
+                icon="cached"
+                data-tooltip="Track available offline"
+              />
+
+              <OverflowMenu>
+                <DropdownItem v-if="!track.isUnavailable" icon="play" @click="setNextInQueue(index)">
+                  Play Next
+                </DropdownItem>
+                <DropdownItem v-if="!track.isUnavailable" icon="queue" @click="addToQueue(index)">
+                  Add to Queue
+                </DropdownItem>
+                <DropdownItem v-if="!isPlaylistView" icon="playlist-add" @click.stop="playlistSelect = index">
+                  Add to Playlist
+                </DropdownItem>
+                <DropdownItem v-if="!track.isStream" :icon="isFavourite[index] ? 'heart-fill' : 'heart'" @click="toggleFavourite(index)">
+                  Favourite
+                </DropdownItem>
+                <DropdownItem v-if="!track.isStream" icon="download" @click="download(index)">
+                  Download
+                </DropdownItem>
+                <DropdownItem divider />
+                <DropdownItem v-if="isPlaylistView" icon="playlist-remove" @click="$emit('remove-track', index)">
+                  Remove
+                </DropdownItem>
+              </OverflowMenu>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <Teleport to="#dialogBoxes">
+      <dialog :open="playlistSelect !== null" @click="playlistSelect = null">
+        <article class="playlist-select" @click.stop>
+          <div v-for="playlist in playlistStore.playlists" :key="'playlistSelect_' + playlist.id" role="button" @click="addToPlaylist(playlist.id)">
+            {{ playlist.name }}
+          </div>
+        </article>
+      </dialog>
+    </Teleport>
+  `
+}
diff --git a/src/components/index.ts b/src/components/index.ts
@@ -0,0 +1,21 @@
+import { ContextMenu } from './ContextMenu.vine'
+import { Dropdown, DropdownItem } from './Dropdown.vine'
+import { EmptyIndicator } from './EmptyIndicator.vine'
+import { Header } from './Header.vine'
+import { Icon } from './Icon.vine'
+import { InfiniteLoader } from './InfiniteLoader.vine'
+import { OverflowMenu } from './OverflowMenu.vine'
+import { Tiles, Tile } from './Tiles.vine'
+
+export const components: any[] = [
+  ContextMenu,
+  Dropdown,
+  DropdownItem,
+  EmptyIndicator,
+  Header,
+  Icon,
+  InfiniteLoader,
+  OverflowMenu,
+  Tile,
+  Tiles,
+]+
\ No newline at end of file
diff --git a/src/env.d.ts b/src/env.d.ts
@@ -0,0 +1,11 @@
+import { SubsonicApi } from './api'
+
+declare module '*.svg'
+
+declare module 'pinia' {
+ export interface PiniaCustomProperties {
+   subsonicApi: SubsonicApi
+ }
+}
+
+export {}+
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
@@ -0,0 +1,101 @@
+import { createApp, markRaw, watch } from 'vue'
+
+import { createPinia } from 'pinia'
+
+import { setupRouter } from './router'
+import { createSubsonicApi } from './subsonicApi'
+
+import { useMainStore } from './store/main'
+import { useFavouriteStore } from './store/favourite'
+import { usePlaylistStore } from './store/playlist'
+import { setupAudio, usePlayerStore } from './store/player'
+
+import { components } from './components'
+import { RootView } from './view/Root.vine'
+
+import './style/main.scss'
+
+const APP_BASE = import.meta.env.BASE_URL ?? '/'
+
+const bootstrapApp = async () => {
+  window.history.scrollRestoration = 'manual'
+
+  // Initialize stores and app
+  const
+    subsonicApi = createSubsonicApi(),
+    pinia = createPinia().use(({ store }) => { store.subsonicApi = markRaw(subsonicApi) }),
+    mainStore = useMainStore(pinia),
+    app = createApp(RootView)
+
+  // Register plugins
+  app.use(pinia)
+  app.use(subsonicApi)
+  app.use(setupRouter(APP_BASE))
+
+  // Register global properties
+  app.config.globalProperties.appName = import.meta.env.APP_NAME ?? 'Domsonic'
+  app.config.globalProperties.appBase = APP_BASE
+
+  // Register components
+  components.forEach(
+    (component: any) => app.component(component.name, component)
+  )
+
+  // set theme color
+  if (mainStore.themeColor !== null)
+    mainStore.applyThemeColor()
+
+  const
+    serverUrl = mainStore.serverUrl,
+    serverCredentials = mainStore.serverCredentials
+
+  if (serverUrl && serverCredentials) {
+    subsonicApi.setServerUrl(serverUrl)
+    subsonicApi.setAuth(serverCredentials)
+    subsonicApi.setStreamFormat(mainStore.streamFormat, mainStore.streamBitrate)
+    subsonicApi.initialize()
+  }
+
+  // Watch logged-in state
+  watch(
+    mainStore.isAuthenticated,
+    async () => {
+      try {
+        if (!subsonicApi.isInitialized())
+          return
+
+        const playerStore = usePlayerStore(pinia)
+
+        // setup player once authenticated
+        void setupAudio(playerStore, mainStore)
+        await Promise.all([
+          useFavouriteStore(pinia).load(),
+          usePlaylistStore(pinia).load(),
+          playerStore.loadQueue(),
+        ])
+      } catch (err) {
+        console.error('Error loading user data', err)
+      }
+    },
+    { immediate: true }
+  )
+
+  try {
+    app.mount('#app')
+  } catch (err) {
+    console.error('App mount failed', err)
+  }
+
+  // Service Worker
+  if ('serviceWorker' in navigator) {
+    void navigator.serviceWorker.register(`${APP_BASE}service-worker.js`, { scope: APP_BASE })
+
+    navigator.serviceWorker.addEventListener('message', (event) => {
+      if (event.data?.type === 'UPDATE_READY')
+        console.log('New version !')
+    })
+  }
+}
+
+// Run the bootstrap
+void bootstrapApp()
diff --git a/src/reload.ts b/src/reload.ts
@@ -0,0 +1,6 @@
+import { ref } from 'vue'
+
+export const reloadToken = ref(0)
+export function pushReload() {
+  reloadToken.value++
+}
diff --git a/src/router.ts b/src/router.ts
@@ -0,0 +1,176 @@
+import { nextTick } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+
+import { useMainStore } from './store/main'
+import { useSubsonicApi } from './subsonicApi'
+
+import { LoginView } from './view/Login.vine'
+import { DiscoverView } from './view/Discover.vine'
+import { PlayerQueueView } from './view/PlayerQueue.vine'
+import { ArtistView } from './view/Artist.vine'
+import { AlbumView } from './view/Album.vine'
+import { GenreView } from './view/Genre.vine'
+import { PlaylistsView } from './view/Playlists.vine'
+import { PlaylistView } from './view/Playlist.vine'
+import { FavouritesView } from './view/Favourites.vine'
+import { SearchResultView } from './view/SearchResult.vine'
+
+export function setupRouter(appBase) {
+  const router = createRouter({
+    history: createWebHistory(appBase),
+    linkExactActiveClass: 'active',
+    scrollBehavior: (to, from, savedPosition) => (
+      (savedPosition)
+      ? (new Promise(
+        resolve => (nextTick().then(() => {
+          if (['discover', 'albums', 'artists', 'genre'].includes(to.name as string)) {
+            setTimeout(() => { resolve({ left: savedPosition.left, top: Math.max(savedPosition.top - 38, 0) }) }, 500)
+          } else {
+            setTimeout(() => { resolve({ left: savedPosition.left, top: Math.max(savedPosition.top - 38, 0) }) }, 100)
+          }
+        }))
+      ))
+      : (
+        (to.hash)
+        ? { el: to.hash, behavior: 'smooth' }
+        : { left: 0, top: 0 }
+      )
+    ),
+    routes: [
+      {
+        name: 'login',
+        path: '/login',
+        component: LoginView,
+        props: (route) => ({
+          returnTo: route.query.returnTo,
+        }),
+      },
+      {
+        path: '/',
+        name: 'discover',
+        component: DiscoverView,
+        meta: { keepAlive: true },
+      },
+      {
+        name: 'queue',
+        path: '/queue',
+        component: PlayerQueueView,
+        meta: { keepAlive: true },
+      },
+      {
+        name: 'albums',
+        path: '/albums',
+        component: AlbumView,
+        meta: { keepAlive: true },
+        props: (route) => ({
+          sort: route.query.sort,
+        }),
+      },
+      {
+        name: 'album',
+        path: '/albums/:id',
+        component: AlbumView,
+        meta: { keepAlive: true, backButton: true },
+        props: true,
+      },
+      {
+        name: 'artists',
+        path: '/artists',
+        component: ArtistView,
+        meta: { keepAlive: true },
+      },
+      {
+        name: 'artist',
+        path: '/artists/:id',
+        component: ArtistView,
+        meta: { keepAlive: true, backButton: true },
+        props: true,
+      },
+      {
+        name: 'artist-tracks',
+        path: '/artists/:id/tracks',
+        component: ArtistView,
+        meta: { keepAlive: true },
+        props: (route) => ({
+          trackView: true,
+          ...route.params,
+        }),
+      },
+      {
+        name: 'genres',
+        path: '/genres',
+        component: GenreView,
+        meta: { keepAlive: true },
+      },
+      {
+        name: 'genre',
+        path: '/genres/:id/:section?',
+        component: GenreView,
+        meta: { keepAlive: true, backButton: true },
+        props: true,
+      },
+      {
+        name: 'favourites',
+        path: '/favourites/:section?',
+        component: FavouritesView,
+        props: true,
+      },
+      {
+        name: 'playlists',
+        path: '/playlists',
+        component: PlaylistsView,
+        meta: { keepAlive: true },
+      },
+      {
+        name: 'playlist',
+        path: '/playlist/:id',
+        component: PlaylistView,
+        meta: { keepAlive: true, backButton: true },
+        props: true,
+      },
+      {
+        name: 'search',
+        path: '/search/:mode?',
+        component: SearchResultView,
+        meta: { backButton: true },
+        props: (route) => ({
+          query: route.query.q,
+          ...route.params,
+        })
+      },
+    ],
+  })
+
+  router.beforeEach(
+    (to) => {
+      if (to.name === 'login')
+        return
+
+      try {
+        const
+          mainStore = useMainStore(),
+          subsonicApi = useSubsonicApi()
+
+        if (!subsonicApi.isInitialized)
+          subsonicApi.initialize()
+
+        if(!mainStore.serverInfo)
+          throw Error()
+      } catch {
+        return {
+          name: 'login',
+          query: {
+            q: to.query.q,
+            returnTo: (
+              (to.fullPath.startsWith(appBase))
+              ? to.fullPath.slice(appBase.length - 1)
+              : to.fullPath
+            ),
+          }
+        }
+      }
+    }
+  )
+
+  return router
+}
diff --git a/src/store/cache.ts b/src/store/cache.ts
@@ -0,0 +1,368 @@
+import { defineStore } from 'pinia'
+
+import type { Album } from '../types'
+
+import { sleep } from '../utils'
+
+const CACHE_NAME = 'domsomic-cache-v1'
+const META_DB_NAME = 'domsonic-cache-meta-v1'
+const META_STORE_NAME = 'entries'
+const META_INFO_STORE_NAME = 'meta'
+const MAX_CACHE_SIZE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
+
+type MetaEntry = {
+  url: string
+  size: number
+  timestamp: number
+  order: number
+  lastAccess: number
+}
+
+type MetaInfo = {
+  id: 'meta'
+  totalBytes: number
+  nextOrder: number
+}
+
+// ---------------------------------------------------------------------------
+// IndexedDB helpers
+// ---------------------------------------------------------------------------
+
+function openMetaDB(): Promise<IDBDatabase> {
+  return new Promise((resolve, reject) => {
+    const req = indexedDB.open(META_DB_NAME, 3)
+
+    req.onupgradeneeded = () => {
+      const db = req.result
+
+      let store: IDBObjectStore
+      if (!db.objectStoreNames.contains(META_STORE_NAME)) {
+        store = db.createObjectStore(META_STORE_NAME, { keyPath: 'url' })
+        store.createIndex('order', 'order')
+        store.createIndex('lastAccess', 'lastAccess')
+      } else {
+        store = req.transaction!.objectStore(META_STORE_NAME)
+        if (!store.indexNames.contains('lastAccess')) {
+          store.createIndex('lastAccess', 'lastAccess')
+        }
+      }
+
+      if (!db.objectStoreNames.contains(META_INFO_STORE_NAME)) {
+        db.createObjectStore(META_INFO_STORE_NAME, { keyPath: 'id' })
+      }
+    }
+
+    req.onsuccess = () => {
+      const db = req.result
+      const tx = db.transaction(META_INFO_STORE_NAME, 'readwrite')
+      const store = tx.objectStore(META_INFO_STORE_NAME)
+
+      const getReq = store.get('meta')
+      getReq.onsuccess = () => {
+        if (!getReq.result) {
+          store.put({ id: 'meta', totalBytes: 0, nextOrder: 1 } as MetaInfo)
+        }
+      }
+
+      tx.oncomplete = () => resolve(db)
+      tx.onerror = () => resolve(db)
+    }
+
+    req.onerror = () => reject(req.error)
+  })
+}
+
+async function getMetaInfo(): Promise<MetaInfo> {
+  const db = await openMetaDB()
+  return new Promise(resolve => {
+    const tx = db.transaction(META_INFO_STORE_NAME, 'readonly')
+    const store = tx.objectStore(META_INFO_STORE_NAME)
+    const req = store.get('meta')
+
+    req.onsuccess = () => {
+      resolve(req.result || { id: 'meta', totalBytes: 0, nextOrder: 1 })
+    }
+
+    req.onerror = () => {
+      resolve({ id: 'meta', totalBytes: 0, nextOrder: 1 })
+    }
+  })
+}
+
+async function touchMeta(url: string) {
+  const db = await openMetaDB()
+  const tx = db.transaction(META_STORE_NAME, 'readwrite')
+  const store = tx.objectStore(META_STORE_NAME)
+
+  const req = store.get(url)
+  req.onsuccess = () => {
+    const entry = req.result as MetaEntry | undefined
+    if (!entry) return
+    entry.lastAccess = Date.now()
+    store.put(entry)
+  }
+}
+
+async function putMeta(url: string, size: number) {
+  const db = await openMetaDB()
+
+  return new Promise<void>((resolve, reject) => {
+    const tx = db.transaction([META_STORE_NAME, META_INFO_STORE_NAME], 'readwrite')
+    const entries = tx.objectStore(META_STORE_NAME)
+    const metaStore = tx.objectStore(META_INFO_STORE_NAME)
+
+    const metaReq = metaStore.get('meta')
+    metaReq.onsuccess = () => {
+      const meta = metaReq.result as MetaInfo
+      const now = Date.now()
+
+      const entry: MetaEntry = {
+        url,
+        size,
+        timestamp: now,
+        order: meta.nextOrder++,
+        lastAccess: now,
+      }
+
+      meta.totalBytes += size
+      entries.put(entry)
+      metaStore.put(meta)
+    }
+
+    tx.oncomplete = () => resolve()
+    tx.onerror = () => reject(tx.error)
+  })
+}
+
+async function deleteMeta(url: string) {
+  const db = await openMetaDB()
+  const tx = db.transaction([META_STORE_NAME, META_INFO_STORE_NAME], 'readwrite')
+  const entries = tx.objectStore(META_STORE_NAME)
+  const metaStore = tx.objectStore(META_INFO_STORE_NAME)
+
+  const entryReq = entries.get(url)
+  entryReq.onsuccess = () => {
+    const entry = entryReq.result as MetaEntry | undefined
+    if (!entry) return
+
+    entries.delete(url)
+
+    const metaReq = metaStore.get('meta')
+    metaReq.onsuccess = () => {
+      const meta = metaReq.result as MetaInfo
+      meta.totalBytes = Math.max(0, meta.totalBytes - entry.size)
+      metaStore.put(meta)
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+// LRU eviction
+// ---------------------------------------------------------------------------
+
+async function enforceCacheLimitLRU() {
+  const cache = await caches.open(CACHE_NAME)
+  const meta = await getMetaInfo()
+  let total = meta.totalBytes
+
+  if (total <= MAX_CACHE_SIZE_BYTES) return
+
+  const db = await openMetaDB()
+
+  return new Promise<void>((resolve, reject) => {
+    const tx = db.transaction([META_STORE_NAME, META_INFO_STORE_NAME], 'readwrite')
+    const store = tx.objectStore(META_STORE_NAME)
+    const index = store.index('lastAccess')
+    const metaStore = tx.objectStore(META_INFO_STORE_NAME)
+
+    const cursorReq = index.openCursor()
+
+    cursorReq.onsuccess = async() => {
+      let cursor = cursorReq.result as IDBCursorWithValue | null
+
+      while (cursor && total > MAX_CACHE_SIZE_BYTES) {
+        const entry = cursor.value as MetaEntry
+
+        await cache.delete(entry.url)
+        store.delete(entry.url)
+        total -= entry.size
+
+        window.dispatchEvent(
+          new CustomEvent('audioCacheDeleted', { detail: entry.url }),
+        )
+
+        cursor = await new Promise<IDBCursorWithValue | null>(resolve => {
+          cursor!.continue()
+          cursor!.request.onsuccess = () =>
+            resolve(cursor!.request.result as IDBCursorWithValue | null)
+          cursor!.request.onerror = () => resolve(null)
+        })
+      }
+
+      const metaReq = metaStore.get('meta')
+      metaReq.onsuccess = () => {
+        const m = metaReq.result as MetaInfo
+        m.totalBytes = total
+        metaStore.put(m)
+      }
+
+      tx.oncomplete = () => {
+        window.dispatchEvent(
+          new CustomEvent('audioCacheEvicted', { detail: { totalBytes: total } }),
+        )
+        resolve()
+      }
+    }
+
+    cursorReq.onerror = () => reject(cursorReq.error)
+  })
+}
+
+// ---------------------------------------------------------------------------
+// Store (FIFO queue enabled)
+// ---------------------------------------------------------------------------
+
+export const useCacheStore = defineStore('albumCache', {
+  state: () => ({
+    activeCaching: new Map<string, { cancelled: boolean }>(),
+    queue: [] as string[],
+    queuedSet: new Set<string>(),
+    processingQueue: false,
+  }),
+
+  actions: {
+    // --------------------------------------------------
+    // FIFO worker
+    // --------------------------------------------------
+
+    async processQueue() {
+      if (this.processingQueue) return
+      this.processingQueue = true
+
+      const cache = await caches.open(CACHE_NAME)
+
+      while (this.queue.length > 0) {
+        const url = this.queue.shift()!
+        this.queuedSet.delete(url)
+
+        try {
+          if (await cache.match(url)) {
+            await touchMeta(url)
+            continue
+          }
+
+          const res = await fetch(url, { mode: 'cors', cache: 'force-cache' })
+          if (!res.ok) continue
+
+          const clone = res.clone()
+          const blob = await res.blob()
+
+          await cache.put(url, clone)
+          await putMeta(url, blob.size)
+          await enforceCacheLimitLRU()
+
+          window.dispatchEvent(
+            new CustomEvent('audioCached', { detail: url }),
+          )
+        } catch (err) {
+          console.error('Cache error:', err)
+        }
+      }
+
+      this.processingQueue = false
+    },
+
+    // --------------------------------------------------
+    // Public enqueue method
+    // --------------------------------------------------
+
+    async cacheTrack(url: string) {
+      if (!url || this.queuedSet.has(url)) return
+
+      this.queue.push(url)
+      this.queuedSet.add(url)
+
+      if (!this.processingQueue) {
+        await this.processQueue()
+      }
+    },
+
+    async hasTrack(url: string) {
+      if (!url) return false
+      const cache = await caches.open(CACHE_NAME)
+      const match = await cache.match(url)
+      if (match) await touchMeta(url)
+      return !!match
+    },
+
+    async deleteTrack(url: string) {
+      if (!url) return
+      const cache = await caches.open(CACHE_NAME)
+      if (await cache.delete(url)) {
+        await deleteMeta(url)
+        window.dispatchEvent(
+          new CustomEvent('audioCacheDeleted', { detail: url }),
+        )
+      }
+    },
+
+    async clearAllAudioCache() {
+      await caches.delete(CACHE_NAME)
+      indexedDB.deleteDatabase(META_DB_NAME)
+      window.dispatchEvent(new CustomEvent('audioCacheClearedAll'))
+      return true
+    },
+
+    async cacheAlbum(album: Album) {
+      if (!album?.tracks?.length) return
+
+      const key = album.id || album.name
+      this.activeCaching.set(key, { cancelled: false })
+      const session = this.activeCaching.get(key)!
+
+      const urls = album.tracks.map(t => t.url).filter(Boolean) as string[]
+
+      for (const url of urls) {
+        if (session.cancelled) return
+        await this.cacheTrack(url)
+        await sleep(200)
+      }
+    },
+
+    async clearAlbumCache(album: Album) {
+      if (!album?.tracks?.length) return
+
+      const key = album.id || album.name
+      if (key && this.activeCaching.has(key)) {
+        this.activeCaching.get(key)!.cancelled = true
+        await sleep(1000)
+      }
+
+      const cache = await caches.open(CACHE_NAME)
+
+      for (const t of album.tracks) {
+        if (!t.url) continue
+        if (await cache.delete(t.url)) {
+          await deleteMeta(t.url)
+          window.dispatchEvent(
+            new CustomEvent('audioCacheDeleted', { detail: t.url }),
+          )
+        }
+      }
+    },
+
+    async isCached(album: Album) {
+      if (!album?.tracks?.length) return false
+      const cache = await caches.open(CACHE_NAME)
+      const res = await Promise.all(
+        album.tracks.map(t => (t.url ? cache.match(t.url) : null)),
+      )
+      return res.every(Boolean)
+    },
+
+    async getCacheSizeGB() {
+      const meta = await getMetaInfo()
+      return Math.round((meta.totalBytes / 1024 ** 3) * 10) / 10
+    },
+  },
+})
diff --git a/src/store/favourite.ts b/src/store/favourite.ts
@@ -0,0 +1,49 @@
+import { defineStore } from 'pinia'
+
+type MediaType = 'track' | 'album' | 'artist'
+
+export const useFavouriteStore = defineStore('favourite', {
+  state: () => ({
+    albums: {} as any,
+    artists: {} as any,
+    tracks: {} as any,
+  }),
+  actions: {
+    load() {
+      return this.subsonicApi.getFavourites().then(result => {
+        this.albums = createIdMap(result.albums)
+        this.artists = createIdMap(result.artists)
+        this.tracks = createIdMap(result.tracks)
+      })
+    },
+    get(type: MediaType, id: string) {
+      console.info('favouriteStore.get(): ', id)
+      const field = getTypeKey(type)
+      return !!this[field][id]
+    },
+    toggle(type: MediaType, id: string) {
+      console.info('favouriteStore.toggle(): ', id)
+      const field = getTypeKey(type)
+      if (this[field][id]) {
+        delete this[field][id]
+        return this.subsonicApi.removeFavourite(id, type)
+      } else {
+        this[field][id] = true
+        return this.subsonicApi.addFavourite(id, type)
+      }
+    },
+  },
+})
+
+function createIdMap(items: [{ id: string }]) {
+  return Object.assign({}, ...items.map((item) => ({ [item.id]: true })))
+}
+
+function getTypeKey(type: string): 'albums' | 'artists' |'tracks' {
+  switch (type) {
+    case 'album': return 'albums'
+    case 'artist': return 'artists'
+    case 'track': return 'tracks'
+    default: throw new Error(`unknown favourite type '${type}'`)
+  }
+}
diff --git a/src/store/main.ts b/src/store/main.ts
@@ -0,0 +1,102 @@
+import { defineStore } from 'pinia'
+import { useLocalStorage, StorageSerializers } from '@vueuse/core'
+import { isMobile } from '../utils'
+
+import type { Auth, StreamFormat, ServerInfo } from '../types'
+
+export const useMainStore = defineStore('main', {
+  state: () => ({
+    serverUrl: useLocalStorage<string | null>('settings.serverUrl', null, { serializer: StorageSerializers.string }),
+    serverCredentials: useLocalStorage<Auth | null>('settings.serverCredentials', null, { serializer: StorageSerializers.object }),
+    serverInfo: useLocalStorage<ServerInfo | null>('settings.serverInfo', null, { serializer: StorageSerializers.object }),
+
+    streamFormat: useLocalStorage<StreamFormat>('settings.streamFormat', 'opus' as StreamFormat),
+    streamBitrate: useLocalStorage<number>('settings.streamBitrate', 128),
+    coverSize: useLocalStorage<number>('settings.coverSize', 512),
+    themeColor: useLocalStorage<string | null>('settings.themeColor', null, { serializer: StorageSerializers.string }),
+    artistAlbumSortOrder: useLocalStorage<'desc' | 'asc'>('settings.artistAlbumSortOrder', 'desc'),
+
+    error: null as null | Error,
+    isLoading: false,
+    menuVisible: false,
+    loaderVisible: false,
+  }),
+
+  actions: {
+    setError(error: Error) {
+      this.error = error
+    },
+    clearError() {
+      this.error = null
+    },
+
+    showMenu() {
+      this.menuVisible = true
+    },
+    hideMenu() {
+      this.menuVisible = false
+    },
+    toggleMenu() {
+      this.menuVisible = !this.menuVisible
+    },
+
+    showLoader() {
+      this.loaderVisible = true
+    },
+    hideLoader() {
+      this.loaderVisible = false
+    },
+
+    toggleArtistAlbumSortOrder() {
+      this.artistAlbumSortOrder = this.artistAlbumSortOrder === 'asc' ? 'desc' : 'asc'
+    },
+
+    setThemeColor (color: string | null) {
+      this.themeColor = color
+    },
+    applyThemeColor () {
+      if (!this.themeColor)
+        return
+
+      const
+        bigint = parseInt(this.themeColor.replace('#', ''), 16),
+        colorRgb = `${(bigint >> 16) & 255}, ${(bigint >> 8) & 255}, ${bigint & 255}`
+
+      document.documentElement.style.setProperty('--accent-hex', this.themeColor)
+      document.documentElement.style.setProperty('--accent-rgb', colorRgb)
+    },
+
+    setServerUrl (url: string) {
+      if (!url || this.serverUrl === url)
+        return
+
+      this.serverUrl = url
+      this.serverCredentials = null
+      this.serverInfo = null
+    },
+    setServerCredentials (auth: Auth) {
+      if (!auth)
+        return
+
+      this.serverInfo = null
+      this.serverCredentials = auth
+    },
+    setServerInfo (serverInfo: ServerInfo) {
+      if (!serverInfo)
+        return
+
+      this.serverInfo = serverInfo
+    },
+
+    isAuthenticated(): boolean {
+      return !!this.serverCredentials
+    },
+
+    setStreamFormat (format: StreamFormat) {
+      this.streamFormat = format
+    },
+    setStreamBitrate (bitrate: number) {
+      this.streamBitrate = bitrate
+    },
+  },
+})
diff --git a/src/store/player.ts b/src/store/player.ts
@@ -0,0 +1,798 @@
+import { defineStore } from 'pinia'
+import { watch } from 'vue'
+import { useLocalStorage } from '@vueuse/core'
+import { throttle } from 'lodash-es'
+
+import { type Track, ReplayGainMode } from '../types'
+import { shuffle, shuffled, trackListEquals, formatArtists, sleep, isMobile } from '../utils'
+import { AudioController } from '../audioController'
+import { useMainStore } from './main'
+import { useRadioStore } from './radio'
+
+
+// ---------------------------------------------------------------------------
+// Module-level singletons
+// ---------------------------------------------------------------------------
+// These are created once and shared across the whole app lifetime.
+// Placing them outside the store avoids re-creation on hot-reload.
+
+/** Browser MediaSession API (undefined on unsupported browsers). */
+const mediaSession: MediaSession | undefined = navigator.mediaSession
+
+/** Singleton Web Audio controller – owns the AudioContext and pipeline. */
+const audio = new AudioController()
+
+// MediaSession requires a non-zero playback rate; 1 = normal speed
+const mediaSessionProgressRate = 1
+
+// ---------------------------------------------------------------------------
+// Pinia store
+// ---------------------------------------------------------------------------
+
+export const usePlayerStore = defineStore('player', {
+  // ── State ─────────────────────────────────────────────────────────────────
+  state: () => ({
+    /** Ordered list of tracks in the current playback queue. */
+    queue: [] as Track[],
+
+    /** Index of the currently playing track within queue (-1 = nothing loaded). */
+    queueIndex: -1,
+
+    /** Duration of the current track in seconds (from audio metadata). */
+    duration: 0.0,
+
+    /** Current playback position in seconds, updated on every timeupdate event. */
+    currentTime: 0.0,
+
+    /** Active ReplayGain normalisation mode. */
+    replayGainMode: useLocalStorage<ReplayGainMode>('player.replayGainMode', ReplayGainMode.None),
+
+    /** Whether the queue loops back to the beginning when it reaches the end. */
+    repeat: useLocalStorage<boolean>('player.repeat', false),
+
+    /** Whether the queue was shuffled when loaded. */
+    shuffle: useLocalStorage<boolean>('player.shuffle', false),
+
+    /** Master volume (0–1). */
+    volume: useLocalStorage<number>('player.volume', 1.0),
+
+    /** True while the audio element is actively playing. */
+    isPlaying: false,
+
+    /** True once the current track has been scrobbled (sent to the API). */
+    scrobbled: false,
+
+    /** True while skipping next/back. */
+    inTransition: false,
+    /**
+     * True when the user explicitly paused.
+     * Used by the mobile auto-resume logic to avoid resuming after an
+     * OS-level interruption if the user had intentionally paused.
+     */
+    wasPaused: true
+  }),
+
+  // ── Getters ───────────────────────────────────────────────────────────────
+  getters: {
+    /** Currently active track, or null when the queue is empty / not started. */
+    track(): Track | null {
+      return this.queue[this.queueIndex] ?? null
+    },
+
+    /** The track that will play after the current one (wraps around). */
+    nextTrack(): Track | null {
+      return this.queue[(this.queueIndex + 1) % this.queue.length] ?? null
+    },
+
+    /** Shorthand for the current track's ID (null when nothing is loaded). */
+    trackId(): string | null {
+      return this.track?.id ?? null
+    },
+
+    /**
+     * Current playback position clamped to [0, duration].
+     * Returns 0 when duration is unknown.
+     */
+    progress(): number {
+      if (this.duration > 0) {
+        return Math.min(this.currentTime, this.duration)
+      }
+      return 0
+    },
+
+    /** True when there is at least one more track after the current one. */
+    hasNext(): boolean {
+      return !!this.queue && (this.queueIndex < this.queue.length - 1)
+    },
+
+    /** True when the current track is not the first in the queue. */
+    hasPrevious(): boolean {
+      return this.queueIndex > 0
+    },
+  },
+
+  // ── Actions ───────────────────────────────────────────────────────────────
+  actions: {
+    /**
+     * Replace the queue with `tracks` and start playing from the first track.
+     * Shuffle is disabled so the order matches what was passed in.
+     */
+    async playNow(tracks: Track[]) {
+      this.setShuffle(false)
+      await this.playTrackList(tracks, 0)
+    },
+
+    /**
+     * Replace the queue with a shuffled version of `tracks` and start playing.
+     * A random starting track is chosen by playTrackList when index is omitted.
+     */
+    async shuffleNow(tracks: Track[]) {
+      this.setShuffle(true)
+      await this.playTrackList(tracks)
+    },
+
+    /**
+     * Jump to a specific queue position without replacing the queue.
+     * Pre-loads the next track's URL into the audio buffer.
+     */
+    async playTrackListIndex(index: number) {
+      this.setQueueIndex(index)
+
+      if (!this.track)
+        return
+
+      await audio.loadTrack({
+        track: this.track,
+        nextTrack: this.nextTrack,
+        fade: true,
+      })
+    },
+
+    /**
+     * Replace the queue (unless it already contains the same tracks) and
+     * start playback from the given index.
+     *
+     * When shuffle is enabled the track list is shuffled in-place so the
+     * chosen starting track ends up at index 0.
+     */
+    async playTrackList(tracks: Track[], index?: number) {
+      if (index == null)
+        // Pick a random start position when shuffling, otherwise start at 0
+        index = this.shuffle ? Math.floor(Math.random() * tracks.length) : 0
+
+      if (this.shuffle) {
+        tracks = [...tracks]
+        shuffle(tracks, index) // Moves the chosen track to position 0
+        index = 0
+      }
+
+      // Avoid resetting the queue if the contents haven't changed
+      if (!trackListEquals(this.queue || [], tracks)) {
+        this.setQueue(tracks)
+      }
+
+      this.setQueueIndex(index)
+
+      if (!this.track)
+        return
+
+      await audio.loadTrack({
+        track: this.track,
+        nextTrack: this.nextTrack,
+        fade: true,
+      })
+    },
+
+    /** Resume playback and update the MediaSession position state. */
+    async play() {
+      this.wasPaused = false
+      await audio.play()
+    },
+
+    /** Pause playback and update the MediaSession position state. */
+    async pause() {
+      this.wasPaused = true
+      await audio.pause()
+    },
+
+    async stop() {
+      this.wasPaused = true
+      await audio.stop()
+      this.setMediaSessionPosition(0, 0)
+      this.setMediaSessionState('none')
+    },
+
+    /** Toggle between play and pause. */
+    async playPause() {
+      if (this.isPlaying) {
+        return this.pause()
+      } else {
+        return this.play()
+      }
+    },
+
+    /**
+     * Advance to the next track.
+     *
+     * @param fade - Whether to cross-fade into the next track.
+     *
+     * If there is no next track and repeat is off, processQueueEnd() is called
+     * which may hand off to the radio store for auto-continuation.
+     */
+    async next(fade = true) {
+      if (this.hasNext || this.repeat) {
+        this.inTransition = true
+        this.setQueueIndex(this.queueIndex + 1)
+
+        if (this.track) {
+          await audio.loadTrack({
+            track: this.track,
+            nextTrack: this.nextTrack,
+            fade,
+          })
+
+          this.inTransition = false
+          sleep(200).then(() => {
+            this.setMediaSessionPosition()
+            this.setMediaSessionState()
+          })
+        }
+      } else {
+        await this.processQueueEnd()
+      }
+    },
+
+    /**
+     * Go back to the previous track.
+     *
+     * If the current track has been playing for more than 3 seconds, restart
+     * it instead of jumping to the previous one.
+     */
+    async back() {
+      if (this.currentTime > 3) {
+        await this.seek(0)
+      } else {
+        this.inTransition = true
+        this.setQueueIndex(this.queueIndex - 1)
+
+        if (!this.track)
+          return
+
+        await audio.loadTrack({
+          track: this.track,
+          nextTrack: this.nextTrack,
+          fade: true,
+        })
+
+        this.inTransition = false
+        sleep(200).then(() => {
+          this.setMediaSessionPosition()
+          this.setMediaSessionState()
+        })
+      }
+    },
+
+    /** Seek to an absolute position in seconds. */
+    async seek(position: number) {
+      audio.seek(position).then(
+        () => sleep(200).then(
+          () => {
+            this.setMediaSessionPosition()
+            this.setMediaSessionState()
+          }
+        )
+      )
+    },
+
+    /**
+     * Restore the play queue from the server (saved on the previous session).
+     * The track is loaded in a paused state and seeked to the saved position.
+     */
+    async loadQueue() {
+      const { tracks, currentTrack, currentTrackPosition } = await this.subsonicApi.getPlayQueue()
+
+      if (!tracks)
+        return
+
+      this.setQueue(tracks)
+      this.setQueueIndex(currentTrack)
+
+      if (!this.track)
+        return
+
+      audio.loadTrack({
+        track: this.track,
+        nextTrack: this.nextTrack,
+        fade: true,
+        paused: true,
+      }).then(() => this.seek(currentTrackPosition))
+    },
+
+    async saveQueue() {
+      if (!navigator.onLine || !this.queue || !this.track)
+        return 
+
+      this.subsonicApi.savePlayQueue(
+        this.queue,
+        this.track,
+        Math.trunc(this.currentTime)
+      ).catch(
+        err => console.info('savePlayQueue aborted:', err)
+      )
+    },
+
+    /**
+     * Restart the queue from index 0 without changing the track list.
+     * Used when radio continuation fails and there is no fallback.
+     */
+    async resetQueue() {
+      if (!this.queue.length || !this.track?.url) {
+        this.setQueueIndex(-1)
+        return
+      }
+
+      this.setQueueIndex(0)
+
+      if (!this.track)
+        return
+
+      await audio.loadTrack({
+        track: this.track,
+        nextTrack: this.nextTrack,
+        fade: true,
+        paused: true,
+      })
+    },
+
+    /**
+     * Remove all tracks from the queue except the currently playing one.
+     * If there is only one track, stop the audio and clear the queue entirely.
+     */
+    async clearQueue() {
+      if (!this.queue.length)
+        return
+
+      const currentTrack = this.queue[this.queueIndex] ?? null
+
+      if (this.queue.length > 1 && currentTrack !== null) {
+        this.setQueue([ currentTrack ])
+        this.setQueueIndex(0)
+      } else {
+        this.setQueue([])
+        this.setQueueIndex(-1)
+        await this.stop()
+      }
+    },
+
+    /**
+     * Push the current playback position to the MediaSession API so that
+     * lock-screen / notification controls show an accurate scrubber.
+     *
+     * All parameters are optional; current store values are used as fallbacks.
+     */
+    async setMediaSessionPosition(_duration?: number, _position?: number) {
+      if (!navigator.mediaSession)
+        return
+
+      _duration ??= this.duration
+      _position ??= this.currentTime
+      navigator.mediaSession.setPositionState({
+        duration: _duration,
+        playbackRate: mediaSessionProgressRate,
+        position: _position,
+      })
+    },
+
+    setMediaSessionState(_state?: MediaSessionPlaybackState) {
+      if (!navigator.mediaSession)
+        return
+
+      if (!_state)
+        _state = this.isPlaying ? 'playing' : 'paused'
+
+      mediaSession!.playbackState = _state
+    },
+
+    /**
+     * Append tracks to the end of the queue.
+     * Deduplicates the trivial case of adding the same single track twice.
+     * In shuffle mode the tracks are randomised before appending.
+     */
+    addToQueue(tracks: Track[]) {
+      const lastTrack = this.queue && this.queue.length > 0 ? this.queue[this.queue.length - 1] : null
+
+      // Only dedup the trivial "same track added twice in a row" case
+      if (tracks.length === 1 && tracks[0]?.id === lastTrack?.id)
+        return
+
+      this.queue?.push(...this.shuffle ? shuffled(tracks) : tracks)
+    },
+
+    /**
+     * Insert tracks immediately after the current queue position so they play
+     * next. Deduplicates if the same single track is already queued next.
+     */
+    setNextInQueue(tracks: Track[]) {
+      if (tracks.length === 1 && tracks[0]?.id === this.nextTrack?.id)
+        return
+
+      this.queue?.splice(this.queueIndex + 1, 0, ...(this.shuffle ? shuffled(tracks) : tracks))
+    },
+
+    /**
+     * Remove a track at the given queue index.
+     * Adjusts queueIndex to keep the current track pointer correct when a
+     * track before the current one is removed.
+     */
+    removeFromQueue(index: number) {
+      this.queue?.splice(index, 1)
+
+      if (index < this.queueIndex)
+        this.queueIndex--
+    },
+
+    /**
+     * Re-shuffle the entire queue, moving the current track to index 0 so
+     * playback is uninterrupted.
+     */
+    shuffleQueue() {
+      if (!this.queue.length)
+        return 
+
+      this.queue = shuffled(this.queue, this.queueIndex)
+      this.queueIndex = 0
+    },
+
+    /**
+     * Cycle through ReplayGain modes (None → Track → Album → None …).
+     */
+    toggleReplayGain() {
+      const mode = (this.replayGainMode + 1) % ReplayGainMode._Length
+
+      audio.setReplayGainMode(mode)
+      this.replayGainMode = mode
+    },
+
+    /** Toggle queue repeat. */
+    toggleRepeat() {
+      this.repeat = !this.repeat
+    },
+
+    /** Toggle shuffle mode. */
+    toggleShuffle() {
+      this.shuffle = !this.shuffle
+    },
+
+    /** Set master volume, apply to audio controller. */
+    setVolume(volume: number) {
+      audio.setVolume(volume)
+      this.volume = volume
+    },
+
+    /** Set the shuffle flag. */
+    setShuffle(toggle: boolean) {
+      this.shuffle = toggle
+    },
+
+    /** Replace the queue and reset the index to -1. */
+    setQueue(queue: Track[]) {
+      this.queue = queue
+      this.queueIndex = -1
+    },
+
+    /**
+     * Called when the queue has no more tracks.
+     * Hands off to the radio store to continue playback from the last track.
+     * Falls back to resetQueue() if radio continuation is unavailable.
+     */
+    async processQueueEnd() {
+      if (!this.track?.url)
+        return
+
+      this.inTransition = true
+
+      try {
+        const radioStore = useRadioStore()
+        await radioStore.continueFromTrack(this.track)
+      } catch {
+        this.resetQueue()
+      }
+
+      this.inTransition = false
+    },
+
+    /**
+     * Update queueIndex and refresh track-level metadata (duration, MediaSession).
+     *
+     * Handles edge cases:
+     *  - Empty queue → index set to -1
+     *  - Index past end with repeat on → wraps to 0
+     *  - Index past end with repeat off → stays at last track (no wrap)
+     */
+    setQueueIndex(index: number) {
+      if (!this.queue || this.queue.length === 0) {
+        this.queueIndex = -1
+        this.duration = 0
+        this.setMediaSessionState('paused')
+        return
+      }
+
+      index = Math.max(0, index) // Guard against negative indices
+
+      if (index >= this.queue.length) {
+        if (this.repeat) {
+          index = 0 // Loop back to start
+        } else {
+          // Stay on the last track; do not advance
+          this.queueIndex = this.queue.length - 1
+          return
+        }
+      }
+
+      this.queueIndex = index
+      if (!this.track)
+        return
+
+      // Reset scrobble flag so the new track can be scrobbled
+      this.scrobbled = false
+
+      // Initialize this.duration & this.currentTime
+      this.duration = this.track.duration
+      this.currentTime = 0
+
+      // Update lock-screen / notification metadata
+      if (mediaSession) {
+        const artwork: MediaImage[] = [];
+
+        if (this.track.image)
+          // Provide artwork at multiple resolutions for different OS contexts
+          artwork.push(
+            { src: this.track.image, sizes: '96x96', type: 'image/png' },
+            { src: this.track.image, sizes: '128x128', type: 'image/png' },
+            { src: this.track.image, sizes: '192x192', type: 'image/png' },
+            { src: this.track.image, sizes: '256x256', type: 'image/png' },
+            { src: this.track.image, sizes: '384x384', type: 'image/png' },
+            { src: this.track.image, sizes: '512x512', type: 'image/png' },
+          )
+
+        navigator.mediaSession.metadata = new MediaMetadata({
+          title: this.track.title || '',
+          artist: formatArtists(this.track.artists) || '',
+          album: this.track.album || '',
+          artwork,
+        })
+      }
+    },
+  },
+})
+
+// ---------------------------------------------------------------------------
+// setupAudio
+// ---------------------------------------------------------------------------
+// Called once at app startup to wire the AudioController events to the store
+// and register MediaSession action handlers.
+
+export function setupAudio(
+  playerStore: ReturnType<typeof usePlayerStore>,
+  mainStore: ReturnType<typeof useMainStore>
+) {
+  playerStore.setMediaSessionState('none')
+
+  // ---------------------------------------------------------------------------
+  // Mobile auto-resume
+  // ---------------------------------------------------------------------------
+  // On iOS/Android the AudioContext is suspended when the browser tab goes to
+  // the background. When the tab becomes visible again we poll until the audio
+  // resumes by itself or we force a reload from the server queue.
+
+  let resumeToken = false
+
+  function autoResume() {
+    // Only attempt auto-resume on mobile, when the user didn't pause manually,
+    // and when the page is currently visible
+    if (!isMobile() || playerStore.wasPaused || resumeToken) return
+
+    resumeToken = true
+
+    const interval = setInterval(async () => {
+      if (playerStore.isPlaying) {
+        // Audio resumed on its own – cancel the polling loop
+        clearInterval(interval)
+        resumeToken = false
+        return
+      }
+
+      try {
+        // Re-load from the server queue and re-start playback
+        await playerStore.loadQueue()
+        await playerStore.play()
+      } catch {}
+    }, 2000)
+  }
+
+  /**
+   *
+   * Watch the playback position to handle two concerns:
+   * 1. Auto-skip: When fewer than 250 ms remain and there is a next track,
+   *    advance early so there is no audible gap between tracks.
+   *
+   * 2. Scrobbling: Once the user has listened to more than 50% of a track,
+   *    report a play to the server (Last.fm-style scrobble).
+   */
+  audio.ontimeupdate = (time: number) => {
+    if (playerStore.inTransition) return
+    playerStore.currentTime = time
+
+    const track = playerStore.track
+    const isPlaying = playerStore.isPlaying
+
+    // Ignore the first 20 s to avoid false triggers on seek or slow loads
+    if (!track || !isPlaying || time < 20) return
+
+    // ── Auto-skip ──────────────────────────────────────────────────────
+    const duration = playerStore.duration
+    const remaining = duration - time
+    if (remaining < 0.25 && playerStore.hasNext) {
+      playerStore.next(false) // No fade – the gapless buffer handles the transition
+      return
+    }
+
+    // ── Scrobble ───────────────────────────────────────────────────────
+    const progress = duration ? time / duration : 0
+    if (!playerStore.scrobbled && progress > 0.5 && track && isPlaying) {
+      playerStore.scrobbled = true
+      playerStore.subsonicApi.scrobble(track.id)
+    }
+  }
+
+  audio.ondurationchange = (duration: number) => {
+    playerStore.duration = duration
+    playerStore.setMediaSessionPosition()
+  }
+
+  // ---------------------------------------------------------------------------
+  // Page lifecycle
+  // ---------------------------------------------------------------------------
+
+  /** On unload: pause, then persist the queue position to the server. */
+  window.addEventListener('beforeunload', () => {
+    playerStore.stop()
+    playerStore.saveQueue()
+  })
+
+  document.addEventListener('visibilitychange', () => {
+    if (document.visibilityState === 'hidden')
+      playerStore.saveQueue()
+  })
+
+  document.addEventListener('resume', autoResume)
+
+  // ---------------------------------------------------------------------------
+  // Playback event handlers
+  // ---------------------------------------------------------------------------
+
+  /** When a track finishes naturally, advance to the next one or end the queue. */
+  audio.onended = async () => {
+    const { hasNext, repeat } = playerStore
+
+    if (hasNext || repeat) {
+      await playerStore.next(true)
+    } else {
+      await playerStore.processQueueEnd()
+    }
+  }
+
+  const onHandler = state => {
+    const isPlaying = state === 'playing'
+
+    playerStore.isPlaying = isPlaying
+    playerStore.setMediaSessionPosition()
+    playerStore.setMediaSessionState(state)
+
+    if (!isPlaying) autoResume() // Kick off polling if this was an OS-level pause
+  }
+
+  audio.onplay = () => onHandler('playing')
+  audio.onpause = () => onHandler('paused')
+  audio.onsuspend = () => onHandler('paused')
+
+  /**
+   * Fatal errors only (ABORTED / SRC_NOT_SUPPORTED).
+   * Transient network/decode errors are retried internally by AudioController;
+   * this fires only once all retries are exhausted via onfailed below,
+   * or immediately for errors that are never worth retrying.
+   */
+  audio.onerror = (error: any) => {
+    console.warn('[Audio] Fatal error', error)
+    mainStore.setError(error)
+
+    // Skip the broken track rather than looping forever on it.
+    if (playerStore.hasNext) {
+      void playerStore.next(true)
+    } else if (playerStore.hasPrevious) {
+      void playerStore.back()
+    } else {
+      void playerStore.resetQueue()
+    }
+  }
+
+  /** Fired by AudioController after every retry delay. */
+  audio.onretrying = (attempt: number, max: number) => console.info(`[Audio] Network error – retrying (${attempt}/${max})…`)
+
+  /** All retries exhausted without a successful load */
+  audio.onfailed = () => console.warn('[Audio] Retries exhausted, waiting for network')
+
+  /** Stall watchdog armed – nothing to do in the store beyond logging. */
+  audio.onstalled = () => console.info('[Audio] Playback stalled, watchdog armed')
+
+  /**
+   * When the browser goes back online, immediately retry if we're supposed
+   * to be playing (the user didn't deliberately pause).
+   */
+  window.addEventListener('online', () => {
+    if (!playerStore.wasPaused) {
+      console.info('[Audio] Network restored – retrying current track')
+      audio.retryCurrentTrack()
+    }
+  })
+
+  // ---------------------------------------------------------------------------
+  // Initialise audio controller with persisted settings
+  // ---------------------------------------------------------------------------
+  audio.setReplayGainMode(playerStore.replayGainMode)
+  audio.setVolume(playerStore.volume)
+
+  // Restore the track that was playing when the page was last open (paused)
+  if (playerStore.track) {
+    audio.loadTrack({
+      track: playerStore.track,
+      nextTrack: playerStore.nextTrack,
+      paused: true
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+  // MediaSession action handlers
+  // ---------------------------------------------------------------------------
+  // These allow OS media controls (lock screen, headphone buttons, etc.)
+  // to control playback.
+  if (mediaSession) {
+    mediaSession.setActionHandler('play', () => playerStore.play())
+    mediaSession.setActionHandler('pause', () => playerStore.pause())
+    mediaSession.setActionHandler('nexttrack', () => playerStore.next(true))
+    mediaSession.setActionHandler('previoustrack', () => playerStore.back())
+    mediaSession.setActionHandler('stop', () => playerStore.pause())
+
+    mediaSession.setActionHandler('seekto', async (details) => {
+      // fastSeek is a hint that the browser is still scrubbing; skip those
+      if (details.fastSeek || details.seekTime === undefined)
+        return
+
+      playerStore.seek(
+        Math.min(details.seekTime, playerStore.duration)
+      )
+    })
+
+    mediaSession.setActionHandler('seekforward', (details) => {
+      const offset = details.seekOffset || 10
+      const position = Math.min(playerStore.currentTime + offset, playerStore.duration)
+
+      playerStore.seek(position)
+    })
+
+    mediaSession.setActionHandler('seekbackward', (details) => {
+      const offset = details.seekOffset || 10
+      const position = Math.max(playerStore.currentTime - offset, 0)
+
+      playerStore.seek(position)
+    })
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // Periodic queue persistence
+  // ---------------------------------------------------------------------------
+  // Save the play queue to the server every 10 s so the position can be
+  // restored on the next page load or on another device.
+  setInterval(() => playerStore.saveQueue(), 10000)
+}+
\ No newline at end of file
diff --git a/src/store/playlist.ts b/src/store/playlist.ts
@@ -0,0 +1,54 @@
+import { defineStore } from 'pinia'
+import { orderBy } from 'lodash-es'
+
+import type { Playlist } from '../types'
+
+export const usePlaylistStore = defineStore('playlist', {
+  state: () => ({
+    playlists: [] as Playlist[],
+  }),
+  actions: {
+    getPlaylist(id: string) {
+      return this.playlists?.find(p => p.id === id) || null
+    },
+    load() {
+      return this.subsonicApi.getPlaylists().then(result => {
+        this.playlists = orderBy(result, 'createdAt')
+      })
+    },
+    create(name: string, tracks?: string[]) {
+      return this.subsonicApi.createPlaylist(name, tracks).then(result => {
+        this.playlists = orderBy(result, 'createdAt')
+      })
+    },
+    async update({ id, name, comment, isPublic }: Playlist) {
+      const playlist = this.playlists?.find(x => x.id === id)
+      if (playlist) {
+        playlist.name = name
+        playlist.comment = comment
+        playlist.isPublic = isPublic
+        await this.subsonicApi.editPlaylist(id, name, comment, isPublic)
+      }
+    },
+    async addTracks(playlistId: string, trackIds: string[]) {
+      const playlist = this.playlists?.find(x => x.id === playlistId)
+      if (playlist) {
+        await this.subsonicApi.addToPlaylist(playlistId, trackIds)
+        playlist.updatedAt = new Date().toISOString()
+        playlist.trackCount = (playlist?.trackCount || 0) + trackIds.length
+      }
+    },
+    async removeTrack(playlistId: string, index: number) {
+      const playlist = this.playlists?.find(x => x.id === playlistId)
+      if (playlist) {
+        await this.subsonicApi.removeFromPlaylist(playlistId, index)
+        playlist.updatedAt = new Date().toISOString()
+        playlist.trackCount = (playlist?.trackCount || 0) - 1
+      }
+    },
+    async delete(id: string) {
+      await this.subsonicApi.deletePlaylist(id)
+      this.playlists = this.playlists!.filter(p => p.id !== id)
+    },
+  },
+})
diff --git a/src/store/radio.ts b/src/store/radio.ts
@@ -0,0 +1,295 @@
+import { nextTick } from 'vue'
+import { useRouter } from 'vue-router'
+import { defineStore } from 'pinia'
+
+import type { Track, AlbumArtist, Album } from '../types'
+
+import { shuffled } from '../utils'
+import { SubsonicApi } from '../subsonicApi'
+import { useMainStore } from '../store/main'
+import { usePlayerStore } from '../store/player'
+
+export const useRadioStore = defineStore('radio', {
+  actions: {
+    // -------- HELPERS --------
+    takeUpToX(acc: Track[], next: Track[]): Track[] {
+      if (acc.length >= 100) return acc
+      const remaining = 100 - acc.length
+      return acc.concat(next.slice(0, remaining))
+    },
+
+    pickRandom<T>(array: T[], count: number): T[] {
+      const copy = [...array]
+      const result: T[] = []
+
+      while (result.length < count && copy.length) {
+        const i = Math.floor(Math.random() * copy.length)
+        const pick = copy.splice(i, 1)[0]
+        if (!!pick) result.push(pick)
+      }
+
+      return result
+    },
+
+    // -------- CORE --------
+    async playRandomOrTracks(opts: { params?: Parameters<SubsonicApi['getRandomTracks']>[0]; tracks?: Track[] }) {
+      const
+        mainStore = useMainStore(),
+        playerStore = usePlayerStore(),
+        router = useRouter()
+
+      let shouldRoute = false
+
+      mainStore.showLoader()
+
+      try {
+        let tracks: Track[] = []
+
+        if (opts.params) {
+          tracks = await this.subsonicApi.getRandomTracks(opts.params)
+        } else if (opts.tracks) {
+          tracks = opts.tracks
+        }
+
+        if (!tracks?.length) return
+
+        const randomized = shuffled(tracks)
+        await playerStore.playNow(randomized)
+        shouldRoute = true
+      } finally {
+        mainStore.hideLoader()
+
+        if (shouldRoute)
+          router.push({ name: 'queue' })
+      }
+    },
+
+    async continueFromTrack(track: Track) {
+      if (!track) return
+
+      // Prefer explicit genre if present
+      let genreName: string | undefined = (track as any).genre
+
+      if (!genreName && track.albumId) {
+        try {
+          const album = await this.subsonicApi.getAlbumDetails(track.albumId)
+          genreName = album.genres?.[0]?.name
+        } catch (err) {
+          console.warn('Radio: failed to resolve album genre', err)
+        }
+      }
+
+      if (!genreName) {
+        console.warn('Radio: no genre found, cannot continue')
+        return
+      }
+
+      await this.playRandomOrTracks({
+        params: { genre: genreName, size: 100 }
+      })
+    },
+
+    // -------- RADIOS --------
+    async shuffleGenre(genreId: string) {
+      await this.playRandomOrTracks({ params: { genre: genreId, size: 100 } })
+    },
+
+    async shuffleMood(genreName: string) {
+      await this.playRandomOrTracks({ params: { genre: genreName, size: 100 } })
+    },
+
+    async luckyRadio() {
+      await this.playRandomOrTracks({ params: { size: 100 } })
+    },
+
+    async shuffleRecentlyPlayed() {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+        const albums: Album[] = this.pickRandom(await this.subsonicApi.getAlbums('recently-played', 50), 10)
+        let tracks: Track[] = []
+
+        for (const album of albums) {
+          const fullAlbum = await this.subsonicApi.getAlbumDetails(album.id)
+          if (fullAlbum.tracks?.length) {
+            tracks = this.takeUpToX(tracks, fullAlbum.tracks)
+            if (tracks.length >= 100) break
+          }
+        }
+
+        if (tracks.length) {
+          await this.playRandomOrTracks({ tracks })
+        }
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async shuffleRecentlyAdded() {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+        const albums: Album[] = this.pickRandom(await this.subsonicApi.getAlbums('recently-added', 50), 10)
+        let tracks: Track[] = []
+
+        for (const album of albums) {
+          const fullAlbum = await this.subsonicApi.getAlbumDetails(album.id)
+          if (fullAlbum.tracks?.length) {
+            tracks = this.takeUpToX(tracks, fullAlbum.tracks)
+            if (tracks.length >= 100) break
+          }
+        }
+
+        if (tracks.length) {
+          await this.playRandomOrTracks({ tracks })
+        }
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async shuffleMostPlayed() {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+      const albums: Album[] = this.pickRandom(await this.subsonicApi.getAlbums('most-played', 50), 10)
+        let tracks: Track[] = []
+
+        for (const album of albums) {
+          const fullAlbum = await this.subsonicApi.getAlbumDetails(album.id)
+          if (fullAlbum.tracks?.length) {
+            tracks = this.takeUpToX(tracks, fullAlbum.tracks)
+            if (tracks.length >= 100) break
+          }
+        }
+
+        if (tracks.length) {
+          await this.playRandomOrTracks({ tracks })
+        }
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async shuffleFavouriteAlbums() {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+        const favourites = await this.subsonicApi.getFavourites()
+        const favouriteAlbums = this.pickRandom(favourites.albums, 10)
+        if (!favouriteAlbums.length) return
+
+        let tracks: Track[] = []
+        for (const album of favouriteAlbums as { id: string }[]) {
+          const fullAlbum = await this.subsonicApi.getAlbumDetails(album.id)
+          if (fullAlbum.tracks?.length) {
+            tracks = this.takeUpToX(tracks, fullAlbum.tracks)
+            if (tracks.length >= 100) break
+          }
+        }
+
+        if (tracks.length) {
+          await this.playRandomOrTracks({ tracks })
+        }
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async shuffleFavouriteArtists() {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+        const favourites = await this.subsonicApi.getFavourites()
+        const randomArtists = this.pickRandom(favourites.artists, 10)
+        if (!randomArtists.length) return
+
+        let tracks: Track[] = []
+
+        for (const artist of randomArtists as { id: string }[]) {
+          let artistTracks: Track[] = []
+          for await (const batch of this.subsonicApi.getTracksByArtist(artist.id)) {
+            artistTracks = artistTracks.concat(batch)
+            if (artistTracks.length >= 20) break
+          }
+          tracks = tracks.concat(artistTracks.slice(0, 20))
+          if (tracks.length >= 100) break
+        }
+
+        if (tracks.length) {
+          await this.playRandomOrTracks({ tracks })
+        }
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async shufflePlaylists() {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+        const playlists = (await this.subsonicApi.getPlaylists()).slice(0, 5)
+        let allTracks: Track[] = []
+
+        for (const p of playlists) {
+          const full = await this.subsonicApi.getPlaylist(p.id)
+          if (full.tracks?.length) {
+            allTracks = this.takeUpToX(allTracks, full.tracks)
+            if (allTracks.length >= 100) break
+          }
+        }
+
+        if (allTracks.length) {
+          await this.playRandomOrTracks({ tracks: allTracks })
+        }
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async shuffleArtist(artistId: string) {
+      const mainStore = useMainStore()
+      mainStore.showLoader()
+
+      try {
+        let tracks: Track[] = []
+        for await (const batch of this.subsonicApi.getTracksByArtist(artistId)) {
+          tracks = this.takeUpToX(tracks, batch)
+          if (tracks.length >= 100) break
+        }
+        await this.playRandomOrTracks({ tracks })
+      } finally {
+        mainStore.hideLoader()
+      }
+    },
+
+    async radioArtist(artistId: string) {
+      await this.playRandomOrTracks({
+        tracks: await this.subsonicApi.getSimilarTracksByArtist(artistId, 50)
+      })
+    },
+
+    async radioAlbum(tracks: Track[], artists: AlbumArtist[]) {
+      if (!tracks?.length || !artists?.length || !artists[0]?.id)
+        return
+
+      const playerStore = usePlayerStore()
+      playerStore.setShuffle(false)
+
+      const similarTracks = await this.subsonicApi.getSimilarTracksByArtist(artists[0].id, 50)
+      if (similarTracks?.length)
+        await this.playRandomOrTracks({ tracks: similarTracks })
+    },
+
+    async shuffleAlbum(tracks: Track[]) {
+      if (!tracks?.length) return
+      await this.playRandomOrTracks({ tracks: tracks.slice(0, 100) })
+    }
+  }
+})
diff --git a/src/style/discover.scss b/src/style/discover.scss
@@ -0,0 +1,22 @@
+.genres {
+  padding: 1rem 0;
+
+  a {
+    text-wrap: nowrap;
+    margin: .5rem;
+    padding: .5rem 1rem;
+    border-radius: 20px;
+    text-decoration: none !important;
+    background: var(--secondary-background);
+  }
+
+  a:hover {
+    color: var(--primary-color);
+  }
+}
+
+.header-title {
+  margin-top: 10px;
+  font-size: 1.5rem;
+}
+
diff --git a/src/style/dropdown.scss b/src/style/dropdown.scss
@@ -0,0 +1,96 @@
+details.dropdown {
+  summary::marker {
+    content: '' !important;
+  }
+
+  &[open] > summary + ul {
+    transform: scaleY(1);
+    opacity: 1;
+  }
+
+  &[open][direction=up] > summary + ul {
+    transform: translateY(calc(-100% + -3.5rem));    
+  }
+
+  &[open] > summary {
+    &::before {
+      display: block;
+      z-index: 1;
+      position: fixed;
+      width: 100vw;
+      height: 100vh;
+      inset: 0;
+      background: none;
+      content: "";
+      cursor: default;
+    }
+  } 
+
+  ul {
+    z-index: 10;
+    position: absolute;
+    display: flex;
+    flex-direction: column;
+    min-width: fit-content;
+    min-height: fit-content;
+    width: 100%;
+    padding: 0;
+    color: var(--color);
+    border: 1px solid var(--secondary-border);
+    border-radius: var(--border-radius);
+    background-color: var(--secondary-background);
+    box-shadow: var(--box-shadow);
+    opacity: 0;
+
+    li:has(a) {
+      padding: 0;
+
+      a {
+        margin: 0;
+      }
+    }
+
+    li {
+      &[role="button"] {
+        margin: unset;
+        border-radius: unset;
+      }
+      
+      &.divider {
+        border-bottom: 1px solid var(--secondary-border);
+        padding: 0 !important;
+      }
+
+      &:not(:has(a)),
+      &> a {
+        width: 100%;
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+        justify-content: space-between;
+        padding: .5rem .75rem !important;
+      }
+
+      &:first-of-type {
+        margin-top: 0 !important;
+      }
+      &:last-of-type {
+        margin-bottom: 0 !important;
+      }
+
+      &:first-of-type:hover {
+        border-radius: calc(var(--border-radius) * 0.8) calc(var(--border-radius) * 0.8) 0 0;
+      }
+      &:last-of-type:hover {
+        border-radius: 0 0 calc(var(--border-radius) * 0.8) calc(var(--border-radius) * 0.8);
+      }
+    }
+  }
+
+  .active,
+  .action:hover {
+    transition: background-color var(--transition), color var(--transition), text-decoration var(--transition), box-shadow var(--transition);
+    background-color: #ffffff0b;
+    cursor: pointer;
+  }
+}+
\ No newline at end of file
diff --git a/src/style/header.scss b/src/style/header.scss
@@ -0,0 +1,160 @@
+.header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  .backdrop {
+    z-index: -1;
+    position: absolute;
+    top: -30%;
+    width: calc(100% - 3rem - var(--sidebar-width));
+    height: 100%;
+
+    transform: scale(1.025);
+    filter: blur(8px);
+    opacity: 0.25;
+
+    background-size: max(100%, 1000px) auto;
+    background-position: center;
+    background-repeat: no-repeat;
+    background-image:
+      linear-gradient(to bottom, transparent, black),
+      var(--backdrop-image);
+  }
+
+  .album-cover {
+    display: block;
+    width: 300px;
+    max-width: 75%;
+    height: auto;
+    border-radius: .5rem;
+    object-fit: cover;
+    aspect-ratio: 1;
+    cursor: pointer;
+  }
+
+  .details {
+    display: flex;
+    flex-direction: column;
+    align-items: start;
+    text-align: left;
+    padding-left: 1.5rem;
+    padding-bottom: .25rem;
+
+    h1 {
+      line-height: 1.2;
+      font-size: calc(1rem + 1.25vw);
+      font-weight: 700 !important;
+      vertical-align: middle;
+
+      a {
+        line-height: 1.2;
+        font-size: calc(.5rem + 1vw);
+        vertical-align: middle;
+        padding: .2rem;
+      }
+    }
+
+    .row {
+      flex-wrap: wrap;
+    }
+
+    .scroll {
+      overflow-y: scroll;
+      scrollbar-width: none;
+      min-height: 0;
+      max-height: 7rem;
+    }
+
+    .seperator::before {
+      content: "•";
+      margin: 0 .5rem !important;
+    }
+  }
+
+  details[open] > ul {
+    width: 8rem;
+    transform: translateX(calc(-50% + -1rem)) !important;
+  }
+}
+
+
+@media (min-width: 850px) {
+  .header {
+    .backdrop {
+      max-width: calc(100vw - var(--sidebar-width));
+    }
+  }
+}
+
+@media (max-width: 850px) {
+  .header {
+    flex-direction: column;
+
+    .album-cover {
+      align-self: center;
+    }
+
+    .details {
+      padding-top: 1rem;
+      align-items: center;
+      text-align: center;
+      padding-left: 0;
+    }
+  }
+}
+
+.main-title {
+  margin: .6rem .3rem;
+  vertical-align: middle;
+  line-height: 1.5;
+  font-size: 1.6rem;
+  font-weight: 700;
+  color: var(--theme-text-muted);
+  text-decoration: none !important;
+  user-select: none;
+
+  .icon {
+    vertical-align: middle;
+    font-size: 1.65rem;
+  }
+}
+
+.section-title {
+  margin: .6rem .3rem;
+  margin-top: 1.5rem;
+  vertical-align: middle;
+  line-height: 1.2;
+  font-size: 1.2rem;
+  font-weight: 700;
+  color: var(--theme-text-muted);
+  text-decoration: none !important;
+  user-select: none;
+
+  .icon {
+    vertical-align: middle;
+    font-size: 1.65rem;
+  }
+}
+
+@media (min-width: 1200px) {
+  details > h1 {
+    font-size: 3rem;
+  }
+}
+
+/* Mobile: slightly smaller font */
+@media (max-width: 767px) {
+  .main-title {
+    max-width: 220px;
+    font-size: 1.2rem !important;
+  }
+  .header-title {
+    max-width: 220px;
+    font-size: 1.2rem !important;
+  }
+  .section-title {
+    max-width: 220px;
+    font-size: 1.1rem !important;
+  }
+}
diff --git a/src/style/input.scss b/src/style/input.scss
@@ -0,0 +1,192 @@
+[role=button],
+button,
+input,
+textarea,
+select {
+  padding: .5rem .75rem;
+  border-radius: var(--border-radius);
+  outline: none;
+}
+
+input,
+textarea,
+select {
+  width: 100%;
+  background: var(--input-background);
+  border: 1px solid var(--input-border);
+  transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
+
+  &:hover {
+    background: var(--input-hover-background);
+  }
+
+  &:focus {
+    border-color: var(--input-focus-border);
+  }
+}
+
+input[type=range] {
+  width: 100%;
+  height: .5rem;
+  padding: .5rem 0;
+
+  &::-moz-range-track,
+  &::-moz-range-progress {
+    width: 100%;
+    height: .5rem;
+  }
+}
+
+input[type=range],
+input[type=range][orientation=vertical] {
+  cursor: pointer;
+  appearance: none;
+  background: none;
+  background-color: transparent;
+
+  &::-moz-range-track,
+  &::-moz-range-progress {
+    border-radius: var(--border-radius);
+  }
+
+  &::-moz-range-track {
+    background-color: var(--input-background);
+  }
+
+  &::-moz-range-progress {
+    background-color: var(--accent-hex);
+  }
+
+  &:active::-moz-range-thumb {
+    transform: scale(1.25);
+  }
+
+  &::-moz-range-thumb {
+    width: 1.25rem;
+    height: 1.25rem;
+    border: none;
+    border-radius: 50%;
+    background-color: var(--accent-hex);
+  }
+}
+
+input[type=range][orientation=vertical] {
+  appearance: slider-vertical;
+  writing-mode: vertical-lr;
+  vertical-align: bottom;
+  direction: rtl;
+  height: 100%;
+  padding: 0 .5rem;
+
+  &::-moz-range-track,
+  &::-moz-range-progress {
+    height: 100%;
+    width: .5rem;
+  }
+}
+
+label:has([type=checkbox]) {
+  width: fit-content;
+  cursor: pointer;
+}
+
+[type=checkbox] {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background-color: var(--primary-background);
+  border-color: var(--primary-border);
+  background-position: center;
+  background-size: 0.75em auto;
+
+  background-color: var(--secondary-background);
+  color: var(--contrast-color);
+
+  width: 1.25em;
+  height: 2.25em;
+  border: 1px solid var(--secondary-border-color);
+  border-radius: var(--border-radius);
+  background-color: var(--secondary-background);
+  line-height: 1.5rem;
+  transition: 0.1s ease-in-out;
+
+  &:before {
+    display: block;
+    aspect-ratio: 1;
+    height: 100%;
+    border-radius: 50%;
+    background-color: var(--color);
+    content: "";
+  }
+
+  &:focus {
+    background-color: var(--switch-background-color);
+    border-color: var(--switch-background-color);
+  }
+
+  &:checked {
+    background-color: var(--primary-background);
+    border-color: var(--primary-border);
+  }
+}
+
+button,
+[role=button] {
+  text-align: center;
+  text-decoration: none;
+  cursor: pointer;
+  margin: .5rem .25rem;
+  border: none;
+  background: none;
+
+  &.active {
+    color: var(--primary-color);
+    background: var(--primary-hover-background);
+  }
+
+  &:hover,
+  &.active:hover {
+    background: var(--primary-hover-background);
+  }
+
+  &.contrast {
+    background: var(--contrast-background);
+    color: var(--contrast-color);
+  }
+  &.contrast:hover {
+    background: var(--contrast-hover-background);
+    color: var(--contrast-color);
+  }
+}
+
+details[open] > summary[role=button] {
+  color: var(--primary-color);
+  background: var(--primary-hover-background);
+}
+
+.playlist-select {
+  background: var(--theme-elevation-1);
+  border-radius: 12px;
+  min-width: 160px;
+  max-width: 90vw;
+  max-height: 200px;
+  overflow-y: auto;
+  scrollbar-color: rgba(0,0,0,0.3) transparent;
+  padding: 8px 0;
+  border: 1px solid var(--theme-elevation-2);
+  box-shadow: 0 2px 10px rgba(0,0,0,0.2);
+  text-align: left;
+
+  div {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-align: left;
+    padding: 10px 16px;
+    cursor: pointer;
+  }
+
+  div:hover {
+    background-color: var(--secondary-background);
+  }
+}
diff --git a/src/style/loader.scss b/src/style/loader.scss
@@ -0,0 +1,30 @@
+.loader-overlay {
+  z-index: 99999;
+  position: fixed;
+  top: 0;
+  left: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100vw;
+  height: 100vh;
+  background: rgba(0, 0, 0, .3);
+
+  img {
+    width: 80px;
+    height: 80px;
+    animation: spin 1s linear infinite;
+  }
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.fade-enter-active, .fade-leave-active {
+  transition: opacity .2s ease;
+}
+
+.fade-enter-from, .fade-leave-to {
+  opacity: 0;
+}
diff --git a/src/style/login.scss b/src/style/login.scss
@@ -0,0 +1,9 @@
+.login {
+  margin: auto;
+  width: 25rem;
+
+  button {
+    width: 100%;
+    background-color: var(--form-element-background-color);
+  }
+}
diff --git a/src/style/main.scss b/src/style/main.scss
@@ -0,0 +1,66 @@
+@use './reset';
+@use './variables';
+@use './utils';
+@use './loader';
+@use './scroll';
+@use './input';
+@use './dropdown';
+@use './navbar';
+@use './search';
+@use './header';
+@use './table';
+@use './modal';
+@use './tiles';
+@use './login';
+@use './discover';
+
+:root {
+  overscroll-behavior: none;
+}
+
+body {
+  color: var(--color);
+  background-color: var(--background);
+}
+
+#app {
+  display: flex;
+  flex-direction: row;
+  min-height: 100vh !important;
+}
+
+main {
+  flex-grow: 1;
+  max-width: 100vw;
+  padding: .5rem 1rem;
+  padding-bottom: calc(var(--player-height) + .5rem);
+}
+
+.logo {
+  user-select: none;
+  display: flex;
+  justify-content: center;
+  padding: 1rem 1rem .75rem;
+}
+
+a {
+  text-decoration: none;
+  color: var(--color);
+
+  &:hover {
+    color: var(--secondary-color);
+    text-decoration: underline;
+  }
+}
+
+@media (max-width: 850px) {
+  main {
+    padding-bottom: calc(var(--navbar-height) + var(--player-height) + .5rem);
+  }
+}
+
+@media (min-width: 850px) {
+  .sidebar+main {
+    max-width: calc(100vw - var(--sidebar-width));
+  }
+}+
\ No newline at end of file
diff --git a/src/style/modal.css b/src/style/modal.css
@@ -0,0 +1,38 @@
+dialog {
+  z-index: 999;
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  right: 0;
+  left: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 100%;
+  min-height: 100%;
+  backdrop-filter: blur(0.375rem);
+  background-color: rgba(15, 17, 20, 0.75);
+
+  &:not([open]),
+  &[open="false"] {
+    display: none;
+  }
+
+  article {
+    background-color: var(--secondary-background);
+    border: 1px solid var(--secondary-border);
+    padding: 1rem;
+    border-radius: 12px;
+    max-width: 400px;
+
+    h2 {
+      text-align: center;
+    }
+
+    footer {
+      display: flex;
+      flex-direction: row;
+      justify-content: center;
+    }
+  }
+}+
\ No newline at end of file
diff --git a/src/style/navbar.scss b/src/style/navbar.scss
@@ -0,0 +1,141 @@
+.sidebar {
+  position: sticky;
+  top: 0;
+  display: flex;
+  flex-direction: column;
+  width: var(--sidebar-width);
+  height: 100%;
+  height: 100vh;
+  background: var(--secondary-background);
+  padding: 0 .5rem;
+  padding-bottom: calc(var(--player-height) + .25rem);
+  overflow-y: scroll;
+  overflow-x: hidden;
+  scrollbar-width: none;
+  user-select: none;
+
+  .divider {
+    display: flex;
+    flex-direction: row;
+    align-content: center;
+    justify-content: space-between;
+    font-weight: 700;
+    font-size: .875em;
+    text-transform: uppercase;
+
+    > * {
+      color: var(--muted-color);
+      padding: .5rem 1rem;
+      margin-top: 1.25em;
+    }
+  }
+
+  a,
+  summary {
+    cursor: pointer;
+    overflow: hidden;
+    min-height: calc(2.5rem);
+    border-radius: var(--border-radius);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-decoration: none !important;
+    text-align: start;
+    padding: .5rem 1rem;
+    transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out;
+
+    svg {
+      font-size: 125%;
+      vertical-align: text-bottom;
+      margin-right: .75rem;
+      pointer-events: none;
+      fill: currentcolor;
+    }
+  }
+
+  a:hover,
+  .active,
+  details[open] > summary {
+    color: var(--primary-color);
+    background-color: var(--primary-hover-background);
+  }
+
+  details {
+    margin-top: auto;
+
+    ul {
+      width: calc(var(--sidebar-width) - 1rem);
+
+      .themes {
+        display: flex;
+        justify-content: space-between;
+
+        button {
+          width: 20px;
+          height: 20px;
+          border-radius: 50%;
+          cursor: pointer;
+          outline: none;
+          padding: 10px;
+          margin: 5px 5px;
+          background: transparent;
+        }
+      }
+    }
+  }
+}
+
+.navbar {
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: var(--navbar-height);
+  max-height: var(--navbar-height);
+  background-color: var(--background);
+  z-index: 3000;
+
+  button {
+    flex: 1;
+    text-align: center;
+    color: var(--secondary-color);
+    font-size: 1.3rem;
+  }
+
+  button.active {
+    color: var(--primary-color);
+  }
+}
+
+@media (min-width: 850px) {
+  .navbar {
+    display: none;
+  }
+}
+
+@media(max-width: 850px) {
+  .sidebar {
+    display: none;
+    transition: all 3.75s ease;
+    z-index: 100;
+  }
+
+  .sidebar nav {
+    padding-bottom: calc(var(--mobile-nav-height) + .25rem) !important;
+  }
+
+  .sidebar.open {
+    display: flex;
+  }
+
+  .sidebar.open + main,
+  .sidebar.open + main + div.player {
+    pointer-events: none;
+    overflow-x: hidden !important;
+    height: 100vh;
+    filter: blur(10px);
+  }
+  
+}+
\ No newline at end of file
diff --git a/src/style/player.css b/src/style/player.css
@@ -0,0 +1,148 @@
+.player {
+  z-index: 50;
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 0;
+  max-height: 0;
+  transition: max-height 0.5s;
+
+  &.visible {
+   height: var(--player-height);
+   max-height: var(--player-height);
+  }
+
+  .playback-slider {
+    margin: 0 auto;
+    margin-bottom: -.8rem;
+
+    &::-moz-range-track,
+    &::-moz-range-progress {
+      border-radius: 0;
+    }
+
+    &::-moz-range-thumb {
+      border: 0;
+      height: 0;
+      width: 0;
+    }
+
+    &:hover::-moz-range-thumb {
+      border-radius: 50%;
+      height: 1.25rem;
+      width: 1.25rem;
+    }
+  }
+
+  .background {
+    display: flex;
+    flex-direction: row;
+    align-content: center;
+    background: var(--secondary-background);
+    color: var(--color);
+    padding: .25rem .5rem;
+    padding-top: calc(.25rem + .3rem);
+    margin-top: -.3rem;
+
+    > div {
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: center;
+      flex: 1;
+    }
+
+    .track-info {
+      justify-content: start;
+
+      img {
+        width: 60px;
+        height: 60px;
+        margin-right: .5rem;
+        cursor: pointer;
+      }
+
+      > div {
+        display: flex;
+        flex-direction: column;
+        min-width: 0;
+
+        > * {
+          display: block;
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
+      }
+
+      button {
+        margin-left: 1rem;
+      }
+    }
+
+    &> :last-child {
+      justify-content: end;
+
+      details .icon,
+      input {
+        margin: auto;
+      }
+    }
+  }
+
+  .volume-slider {
+    height: 9rem;
+    width: 1.6rem;
+    margin: auto;
+  }
+
+  button,
+  [role=button] {
+    padding: .5rem;
+  }
+
+  .play {
+    padding: .75rem;
+
+    .icon {
+      height: 40px;
+      width: 40px;
+    }
+  }
+
+  .previous,
+  .next {
+    .icon {
+      height: 20px;
+      width: 20px;
+    }
+  }
+
+  .previous {
+    .icon {
+      transform: rotate(180deg);
+    }
+  }
+}
+
+@media(max-width: 850px) {
+  .player {
+    bottom: var(--navbar-height);
+
+    .background {
+      > div {
+        flex: unset;
+
+        &:first-child {
+          flex: 1;
+        }
+      }
+    }
+  }
+}
+
+@keyframes slide-text {
+  0% { transform: translateX(35%); }
+  100% { transform: translateX(-65%); }
+}
diff --git a/src/style/reset.scss b/src/style/reset.scss
@@ -0,0 +1,60 @@
+/* 1. Use a more-intuitive box-sizing model */
+*, *::before, *::after {
+  box-sizing: border-box;
+}
+
+/* 2. Remove default margin */
+*:not(dialog) {
+  margin: 0;
+}
+
+/* 3. Enable keyword animations */
+@media (prefers-reduced-motion: no-preference) {
+  html {
+    interpolate-size: allow-keywords;
+  }
+}
+
+body {
+  /* 4. Increase line-height */
+  line-height: 1.5;
+  /* 5. Improve text rendering */
+  -webkit-font-smoothing: antialiased;
+}
+
+/* 6. Improve media defaults */
+img, picture, video, canvas, svg {
+  display: block;
+  max-width: 100%;
+}
+
+/* 7. Inherit fonts for form controls */
+input, button, textarea, select {
+  font: inherit;
+}
+
+/* 8. Avoid text overflows */
+p, h1, h2, h3, h4, h5, h6 {
+  overflow-wrap: break-word;
+}
+
+/* 9. Improve line wrapping */
+p {
+  text-wrap: pretty;
+}
+h1, h2, h3, h4, h5, h6 {
+  text-wrap: balance;
+}
+
+/*
+  10. Create a root stacking context
+*/
+#root, #__next {
+  isolation: isolate;
+}
+
+dialog,
+fieldset,
+input[type=range] {
+  border: none;
+}
diff --git a/src/style/scroll.scss b/src/style/scroll.scss
@@ -0,0 +1,32 @@
+.scroll {
+  max-width: 100%;
+  overflow-x: scroll;
+  scrollbar-color: rgba(0,0,0,0.3) transparent;
+}
+
+.scroll::-webkit-scrollbar {
+  height: 20px;
+}
+
+.scroll::-webkit-scrollbar-button {
+  display: none;
+}
+
+.scroll::-webkit-scrollbar-thumb {
+  background-color: rgba(0, 0, 0, 0.3);
+  border-radius: 10px;
+}
+
+.scroll::-webkit-scrollbar-track {
+  background: transparent;
+  border-radius: 10px;
+}
+
+@media (max-width: 768px) {
+  .scroll {
+    scrollbar-width: none;
+  }
+  .scroll::-webkit-scrollbar {
+    display: none;
+  }
+}
diff --git a/src/style/search.scss b/src/style/search.scss
@@ -0,0 +1,24 @@
+.search {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  align-content: center;
+  margin-top: .75rem;
+  margin-bottom: 1.25rem;
+
+  button {
+    padding: .35rem;
+    margin: .25rem;
+  }
+
+  input[type="search"] {
+    height: 2.25rem;
+    margin-bottom: 0;
+  }
+}
+
+@media (min-width: 850px) {
+  .search > input[type="search"] {
+  	width: 40vw;
+  }
+}+
\ No newline at end of file
diff --git a/src/style/table.scss b/src/style/table.scss
@@ -0,0 +1,117 @@
+table {
+  width: 100%;
+  color: var(--color);
+  border-collapse: separate;
+  border-spacing: 0;
+
+  button,
+  details > [role="button"],
+  details[open] > [role="button"] {
+    max-width: fit-content;
+    padding: .25rem .5rem !important;
+    margin: 0 0 0 auto;
+
+    .icon {
+      margin: .25rem;
+    }
+  }
+
+  .cover {
+    width: 24px;
+    height: 24px;
+    float: left;
+    margin-right: .25rem;
+  }
+
+  .icon {
+    border: none !important;
+  }
+
+  details[open] > ul {
+    width: 12rem;
+    transform: translateX(calc(-75%)) !important;
+  }
+
+  th {
+    font-size: .8rem;
+    text-align: start;
+    text-transform: uppercase;
+    color: var(--muted-color);
+    padding: .5rem .15rem;
+    user-select: none;
+  }
+
+  tbody {
+    tr:not(.disabled) {
+      cursor: pointer;
+    }
+
+    tr:hover,
+    tr.active {
+      td {
+        background: var(--input-background);
+      }
+
+      td:first-child {
+        border-radius: var(--border-radius) 0 0 var(--border-radius);
+      }
+          
+      td:last-child {
+        border-radius: 0 var(--border-radius) var(--border-radius) 0;
+      }
+    }
+
+    tr.active,
+    tr.active a {
+      color: var(--primary-color);
+    }
+
+    tr.disabled {
+      color: var(--muted-color) !important;
+    }
+  }
+}
+
+table.numbered {
+  th:first-child,
+  td:first-child {
+    width: 26px;
+    max-width: 26px;
+    text-align: center;
+    color: var(--muted-color);
+  }
+
+  tbody {
+    tr td:first-child {
+      .number {
+        display: unset;
+        white-space: nowrap;
+      }
+
+      .icon {
+        display: none;
+      }
+    }
+
+    tr.disabled td:first-child {
+      .number,
+      .icon {
+        display: none;
+      }
+    }
+
+    tr.active,
+    tr:hover {
+      td:first-child {
+        .number {
+          display: none;
+        }
+        .icon {
+          display: unset;
+          color: var(--primary-color);
+        }
+      }
+    }
+  }
+}
+
diff --git a/src/style/tiles.scss b/src/style/tiles.scss
@@ -0,0 +1,74 @@
+.tile {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid var(--secondary-border);
+  border-radius: var(--border-radius);
+  background-color: var(--secondary-background);
+
+  .image {
+    width: var(--tile-size-active);
+    height: var(--tile-size-active);
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+    }
+  }
+
+  .body {
+    padding: 1rem;
+    color: var(--muted-color);
+
+    .title {
+      color: var(--color);
+      font-weight: 700;
+    }
+  }
+}
+
+.tiles {
+  --tile-size-active: var(--tile-size);
+  display: grid;
+  gap: 15px;
+
+  &:not(.scroll) {
+    grid-template-columns: repeat(auto-fit, var(--tile-size-active));
+    grid-auto-columns: minmax(var(--tile-size-active), auto);
+  }
+
+  &.scroll {
+    grid-auto-flow: column;
+    grid-auto-columns: var(--tile-size-active);
+    overflow-x: auto;
+  }
+
+  &.two-rows { 
+    .tile {
+      flex-direction: row;
+      align-items: center;
+
+      .image {
+        flex-shrink: 0;
+      }
+      .body {
+        padding: .7rem;
+      }
+    }
+
+    &.scroll {
+      font-size: 0.85rem;
+      grid-auto-flow: column;
+      grid-template-rows: repeat(2, auto);
+      grid-auto-columns: calc(var(--tile-size-active) + 175px);
+    }
+  }
+}
+
+@media (max-width: 850px) {
+  .tiles {
+    --tile-size-active: var(--tile-size-mobile);
+    font-size: 0.65rem;
+  }
+}
diff --git a/src/style/utils.scss b/src/style/utils.scss
@@ -0,0 +1,212 @@
+.invisible {
+  visibility: hidden !important;
+}
+
+.noselect {
+  user-select: none;
+}
+
+.bold {
+  font-weight: 700;
+}
+
+.small {
+  font-size: 0.8rem;
+}
+
+.xsmall {
+  font-size: 0.6rem;
+}
+
+.cover {
+  object-fit: cover;
+  flex-shrink: 0;
+  border-radius: var(--border-radius);
+}
+
+.icon {
+  display: inline;
+  font-size: 125%;
+  vertical-align: text-bottom;
+  pointer-events: none;
+}
+
+.p0 {
+  padding: 0 !important;
+}
+
+.badge {
+  margin: 0 .4rem;
+  padding: .15rem .3rem;
+  font-size: .75rem;
+  font-weight: 700;
+  color: var(--contrast-color);
+  background: var(--muted-color);
+  border-radius: 50rem;
+}
+
+.text-truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.text-nowrap {
+  white-space: nowrap !important;
+}
+
+.text-end {
+  text-align: right;
+}
+
+.text-center {
+  text-align: center;
+}
+
+.text-start {
+  text-align: left;
+}
+
+.accent-color {
+  color: var(--accent-hex);
+}
+
+.color-muted {
+  color: var(--muted-color) !important;
+}
+
+.margin-auto {
+  margin: auto;
+}
+
+.row {
+  display: flex;
+  flex-direction: row;
+}
+
+.col {
+  display: flex;
+  flex-direction: column;
+}
+
+.flex-grow {
+  flex-grow: 1;
+}
+
+.flex-wrap {
+  flex-wrap: wrap !important;
+}
+
+.flex-nowrap {
+  flex-wrap: nowrap;
+}
+
+.justify-content-start {
+  justify-content: start;
+}
+
+.justify-content-center {
+  justify-content: center;
+}
+
+.justify-content-end {
+  justify-content: end;
+}
+
+.justify-content-space-between {
+  justify-content: space-between;
+}
+
+.align-items-center {
+  align-items: center;
+}
+
+.align-items-start {
+  align-items: start;
+}
+
+.align-content-center {
+  align-content: center;
+}
+
+.w-fit-content {
+  min-width: fit-content;
+  max-width: fit-content;
+}
+
+.w-25 { width: 25%; }
+.w-50 { width: 50%; }
+.w-75 { width: 75%; }
+.w-100 {  width: 100%; }
+
+.rounded {
+  border-radius: var(--border-radius);
+}
+
+.float-left {
+  float: left;
+}
+
+.empty {
+  margin: 5rem;
+  text-align: center;
+
+  svg {
+  	font-size: 8em;
+  	color: #222;
+  }
+}
+
+[aria-busy="true"]:not(input, select, textarea, html, form) {
+  white-space: nowrap;
+
+  &::before {
+    display: inline-block;
+    width: 1em;
+    height: 1em;
+    background-image: url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E");
+    background-size: 1em auto;
+    background-repeat: no-repeat;
+    content: "";
+    vertical-align: -0.125em;
+  }
+
+  &:not(:empty) {
+    &::before {
+      margin-inline-end: .5rem;
+    }
+  }
+
+  &:empty {
+    text-align: center;
+  }
+}
+
+button,
+[type="submit"],
+[type="button"],
+[type="reset"],
+[role="button"],
+a {
+  &[aria-busy="true"] {
+    pointer-events: none;
+  }
+}
+
+@media (max-width: 850px) {
+  .hide-mobile {
+    display: none !important;
+  }
+}
+
+@media (min-width: 850px) {
+  .hide-desktop {
+    display: none !important;
+  }
+}
+
+@media (max-width: 1450px) {
+  .hide-tablet {
+    display: none !important;
+  }
+}
diff --git a/src/style/variables.scss b/src/style/variables.scss
@@ -0,0 +1,37 @@
+:root {
+  color-scheme: dark;
+  --font-size: 1rem;
+  --accent-hex: gray;
+  --color: rgb(204, 204, 204);
+  --muted-color: rgba(204, 204, 204, .75);
+  --background: black;
+  --border-color: var(--accent-hex);
+  --border-radius: .35rem;
+  --primary-background: var(--accent-hex);
+  --primary-color: var(--accent-hex);
+  --primary-hover-background: rgba(255, 255, 255, .04);
+  --primary-border: var(--accent-hex);
+  --secondary-color: rgb(163, 163, 163);
+  --secondary-background: #141414;
+  --secondary-hover-background: rgba(255, 255, 255, .04);
+  --secondary-border: rgb(46, 46, 46);
+  --contrast-color: black;
+  --contrast-background: white;
+  --contrast-hover-background: rgba(255, 255, 255, .75);
+  --contrast-border: rgba(255, 255, 255, .75);
+  --input-spacing-vertical: .5rem;
+  --input-spacing-horizontal: .5rem;
+  --input-border: rgba(255, 255, 255, .1);
+  --input-background:  rgba(255, 255, 255, .06);
+  --input-hover-background:  rgba(255, 255, 255, .08);
+  --input-focus-border: rgba(var(--accent-rgb), .8);
+  --navbar-height: 4rem;
+  --sidebar-width: 250px;
+  --player-height: 7rem;
+}
+
+@media(max-width: 850px) {
+  :root {
+    --sidebar-width: 350px;
+  }
+}
diff --git a/src/subsonicApi.ts b/src/subsonicApi.ts
@@ -0,0 +1,808 @@
+import { type App, type Plugin, inject } from 'vue'
+import { orderBy, sumBy, uniqBy, startCase } from 'lodash-es'
+import { Md5 } from 'ts-md5';
+
+import { randomString } from './utils'
+
+import fallbackImage from './assets/fallback.svg';
+
+import type {
+  Auth, ServerInfo, StreamFormat,
+  Playlist, PlayQueue,
+  Album, AlbumGenre, AlbumSort,
+  Artist, Track,
+  SearchMode, SearchResult,
+} from './types'
+
+export class UnsupportedOperationError extends Error { }
+
+export class SubsonicError extends Error {
+  readonly code: string | null
+  constructor(message: string, code: string | null) {
+    super(message)
+    this.name = 'SubsonicError'
+    this.code = code
+  }
+}
+
+export class OfflineError extends Error {
+  constructor() {
+    super('Offline')
+    this.name = 'OfflineError'
+  }
+}
+
+
+export const useSubsonicApi = (): SubsonicApi => (inject('subsonicApi') as SubsonicApi)
+export const createSubsonicApi = (): SubsonicApi & Plugin => {
+  const instance = new SubsonicApi()
+
+  return Object.assign(instance, {
+    install: (app: App) => {
+      app.provide('subsonicApi', instance)
+    }
+  })
+}
+
+export class SubsonicApi {
+  public static clientName = import.meta.env.VITE_APP_NAME ?? 'Domsonic'
+  public static staticParams = {
+    f: 'json',
+    v: "1.16.1",
+    c: SubsonicApi.clientName,
+  }
+
+  public serverUrl: string = ''
+  public auth: { u: string, s: string, t: string } | null = null
+
+  public streamFormat: StreamFormat | null = null
+  public streamBitrate: number | null = null
+  public coverSize: number | null = null
+
+  private initialized: boolean = false
+
+  private readonly fetch = async (endpoint: string, params?: any): Promise<any> => {
+   const
+      url = new URL(endpoint, this.serverUrl),
+      searchParams = new URLSearchParams({
+        ...SubsonicApi.staticParams,
+        ...this.auth,
+        ...params,
+      }),
+      reqParams = {
+        method: 'POST',
+        headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', },
+        body: searchParams,        
+      },
+      request = new Request(url, reqParams)
+
+    try {
+      const response = await fetch(request)
+
+      if (!response.ok) {
+        if (response.status === 501)
+          throw new UnsupportedOperationError(
+            `Request failed with status ${response.status}`
+          )
+
+        throw new Error(`Request failed with status ${response.status}`)
+      }
+
+      const
+        json = await response.json(),
+        subsonicResponse = json['subsonic-response']
+
+      if (subsonicResponse.status !== 'ok')
+        throw new SubsonicError(
+          subsonicResponse.error?.message || subsonicResponse.status,
+          subsonicResponse.error?.code ?? null
+        );
+
+      return subsonicResponse
+    } catch (err: any) {
+      if (err instanceof TypeError && !navigator.onLine) {
+        console.info('[Offline mode] Api request skipped:', endpoint)
+        throw new OfflineError()
+      }
+
+      throw err
+    }
+  }
+
+  public static createAuth(username: string, password: string): Auth {
+    const salt = randomString()
+    return {
+      username,
+      salt,
+      hash: Md5.hashStr(password + salt),
+    }
+  }
+
+  constructor() {}
+
+  setServerUrl = (url: string) => {
+    if (!URL.parse(url))
+      throw new Error(
+        'Invalid server url supplied.'
+      )
+
+    this.initialized = false
+    this.auth = null
+    this.serverUrl = url
+  }
+
+  setAuth = (auth: Auth) => {
+    if (!auth)
+      throw new Error(
+        'Invalid Auth object supplied.'
+      )
+
+    this.initialized = false
+    this.auth = {
+      u: auth.username,
+      s: auth.salt,
+      t: auth.hash,
+    }
+  }
+
+  setStreamFormat = (format: StreamFormat, bitrate: number) => {
+    if (!format)
+      throw new Error(
+        'Invalid format specified'
+      )
+
+    if (typeof bitrate !== 'number')
+      throw new Error(
+        'Invalid bitrate specified'
+      )
+
+    this.streamFormat = format
+    this.streamBitrate = bitrate
+  }
+
+  setCoverSize = (size: number) => {
+    if (!size)
+      throw new Error(
+        'Invalid size specified'
+      )
+
+    this.coverSize = size
+  }
+
+  isInitialized = (): boolean => this.initialized
+  checkInitialized = (): boolean => {
+    if (!this.initialized)
+      throw Error(
+        'Not initialized.'
+      )
+
+    return true
+  }
+
+  initialize = (): boolean => {
+    if (this.serverUrl === '')
+      throw new Error(
+        'No server-url set.'
+      )
+
+    if (!this.auth)
+      throw new Error(
+        'No credentials set.'
+      )
+
+    this.initialized = true
+    return true
+  }
+
+  async fetchServerInfo(): Promise<ServerInfo> {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getOpenSubsonicExtensions')
+
+    if (!response || response.status !== 'ok' )
+      throw new Error(
+        response?.error?.message ||
+        response?.status ||
+        'Unknown error'
+      )
+
+    if (!response?.openSubsonic)
+      throw new Error(
+        'This server is not OpenSubsonic compatible.'
+      )
+
+    return {
+      name: response.type,
+      version: response.version,
+      openSubsonic: true,
+      extensions: (response.openSubsonicExtensions ?? []).map(
+        (ext: any) => ext.name
+      ),
+    }
+  }
+
+  async isOnline(): Promise<boolean> {
+    this.checkInitialized()
+
+    try {
+      const response = await this.fetch('/rest/ping', {})
+      return response?.status === 'ok'
+    } catch (err) {
+      if (err instanceof OfflineError) return false
+      return false
+    }
+  }
+
+  async getGenres() {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getGenres', {})
+    return (response.genres.genre || [])
+      .map((item: any) => ({
+        id: item.value,
+        name: item.value,
+        albumCount: item.albumCount ?? 0,
+        trackCount: item.songCount ?? 0,
+      }))
+      .sort((a: any, b:any) => b.albumCount - a.albumCount)
+  }
+
+  async getAlbumsByGenre(
+    id: string,
+    size: number,
+    offset = 0,
+    random = false,
+  ) {
+    this.checkInitialized()
+
+    const
+      response = await this.fetch('/rest/getAlbumList2', {
+        type: 'byGenre',
+        genre: id,
+        size,
+        offset,
+      }),
+      albums = (response.albumList2?.album || []).map(
+        this.normalizeAlbum,
+        this,
+      )
+
+    if (!random) {
+      // Fisher–Yates shuffle (in-place)
+      for (let i = albums.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1))
+        ;[albums[i], albums[j]] = [albums[j], albums[i]]
+      }
+    }
+
+    return albums
+  }
+
+  async getTracksByGenre(id: string, size: number, offset = 0) {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getSongsByGenre', {
+      genre: id,
+      count: size,
+      offset,
+    })
+
+    return (response.songsByGenre?.song || []).map(this.normalizeTrack, this)
+  }
+
+  async getSimilarTracksByArtist(id: string, size = 50): Promise<Track[]> {
+    this.checkInitialized()
+
+    const
+      artist = await this.getArtistDetails(id),
+      albums = artist.albums || []
+
+    if (!albums.length) return []
+
+    const genreWeightMap: Record<string, number> = {}
+
+    for (const alb of albums) {
+      for (const g of alb.genres || []) {
+        genreWeightMap[g.name] = (genreWeightMap[g.name] || 0) + 1
+      }
+    }
+
+    const
+      weightedGenres = Object.entries(genreWeightMap).flatMap(
+        ([genre, count]) => Array(count).fill(genre)
+      )
+
+    if (!weightedGenres.length) return []
+
+    const chosenGenre = weightedGenres[Math.floor(Math.random() * weightedGenres.length)]
+
+    return this.getRandomTracks({ genre: chosenGenre, size })
+  }
+
+  async getArtists(): Promise<Artist[]> {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getArtists')
+    return (
+      (response.artists?.index || [])
+        .flatMap((index: any) => index.artist)
+        .map(this.normalizeArtist, this)
+    )
+  }
+
+  async getAlbums(sort: AlbumSort, size: number, offset = 0): Promise<Album[]> {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getAlbumList2', {
+      type: {
+        'a-z': 'alphabeticalByName',
+        'recently-added': 'newest',
+        'recently-played': 'recent',
+        'most-played': 'frequent',
+        random: 'random',
+      }[sort],
+      offset,
+      size 
+    })
+
+    return (response.albumList2?.album || []).map(this.normalizeAlbum, this)
+  }
+
+  async getArtistDetails(id: string): Promise<Artist> {
+    this.checkInitialized()
+
+    const artist = await this.fetch('/rest/getArtist', { id }).then(r => r.artist)
+
+    return this.normalizeArtist({
+      topSongs: await this.fetch('/rest/getTopSongs', { artist: artist.name }).then(r => r.topSongs?.song),
+      album: artist.album,
+      ...(await this.fetch('/rest/getArtistInfo2', { id }).then(r => r.artistInfo2)),
+      ...artist,
+    })
+  }
+
+  async * getTracksByArtist(id: string): AsyncGenerator<Track[]> {
+    this.checkInitialized()
+
+    const
+      artist = await this.fetch('/rest/getArtist', { id }).then(r => r.artist),
+      albumIds = orderBy(artist.album || [], x => x.year || 0, 'desc').map(x => x.id),
+      pending = albumIds.map(albumId => this.getAlbumDetails(albumId))
+
+    for (const promise of pending) {
+      const { tracks } = await promise
+      if (tracks?.length) yield tracks
+    }
+  }
+
+  async getAlbumDetails(id: string): Promise<Album> {
+    this.checkInitialized()
+
+    const
+      params = { id },
+      [info, info2] = await Promise.all([
+        this.fetch('/rest/getAlbum', params),
+        this.fetch('/rest/getAlbumInfo2', params),
+      ])
+
+    return this.normalizeAlbum({
+      ...info.album,
+      ...info2.albumInfo
+    })
+  }
+
+  async getPlaylists() {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getPlaylists')
+    return (response.playlists?.playlist || []).map(this.normalizePlaylist, this)
+  }
+
+  async getPlaylist(id: string): Promise<Playlist> {
+    this.checkInitialized()
+
+    if (id === 'random') {
+      const tracks = await this.getRandomTracks()
+ 
+      return {
+        id,
+        name: 'Random',
+        comment: '',
+        createdAt: '',
+        updatedAt: '',
+        duration: sumBy(tracks, 'duration'),
+        isPublic: false,
+        isReadOnly: true,
+        trackCount: tracks.length,
+        tracks,
+      }
+    }
+
+    const response = await this.fetch('/rest/getPlaylist', {
+      id
+    })
+
+    return {
+      ...this.normalizePlaylist(response.playlist),
+      tracks: (response.playlist.entry || []).map(this.normalizeTrack, this),
+    }
+  }
+
+  async createPlaylist(name: string, tracks?: string[]) {
+    this.checkInitialized()
+
+    await this.fetch('/rest/createPlaylist', {
+      songId: tracks,
+      name,
+    })
+
+    return this.getPlaylists()
+  }
+
+  async editPlaylist(playlistId: string, name: string, comment: string, isPublic: boolean) {
+    this.checkInitialized()
+
+    await this.fetch('/rest/updatePlaylist', {
+      playlistId,
+      name,
+      comment,
+      public: isPublic,
+    })
+  }
+
+  async deletePlaylist(id: string) {
+    this.checkInitialized()
+
+    await this.fetch('/rest/deletePlaylist', {
+      id
+    })
+  }
+
+  async addToPlaylist(playlistId: string, tracks: string[]) {
+    this.checkInitialized()
+
+    await this.fetch('/rest/updatePlaylist', {
+      songIdToAdd: tracks,
+      playlistId,
+    })
+  }
+
+  async removeFromPlaylist(playlistId: string, index: number) {
+    this.checkInitialized()
+
+    await this.fetch('/rest/updatePlaylist', {
+      songIndexToRemove: index,
+      playlistId,
+    })
+  }
+
+  async getPlayQueue(): Promise<PlayQueue> {
+    this.checkInitialized()
+
+    const
+      response = await this.fetch('/rest/getPlayQueue'),
+      tracks = (response.playQueue?.entry || []).map(this.normalizeTrack, this) as Track[],
+      currentTrackId = response.playQueue?.current?.toString(),
+      index = tracks.findIndex(track => track.id === currentTrackId),
+      currentTrack =
+        (index >= 0)
+        ? index 
+        : 0
+
+    return {
+      currentTrackPosition: (response.playQueue?.position || 0) / 1000,
+      currentTrack,
+      tracks,
+    }
+  }
+
+  async savePlayQueue(
+    tracks: Track[],
+    currentTrack: Track | null,
+    currentTime: number | null
+  ) {
+    this.checkInitialized()
+
+    try {
+      const tracksIds = tracks.filter(t => !t.isStream).map(t => t.id)
+
+      await this.fetch('/rest/savePlayQueue', {
+        id: tracksIds,
+        current:
+          (!currentTrack?.isStream)
+          ? currentTrack?.id
+          : undefined,
+        position:
+          (currentTime !== null)
+          ? Math.round(currentTime * 1000)
+          : undefined,
+      })
+    } catch (err: any) {
+      if (
+        err instanceof OfflineError ||
+        err.code === 0 || err.code === 10
+      ) return
+
+      throw err
+    }
+  }
+
+  async getRandomTracks(
+    {
+      size = 200,
+      genre,
+      fromYear,
+      toYear,
+    }: {
+      size?: number
+      genre?: string
+      fromYear?: number
+      toYear?: number
+    } = {}
+  ): Promise<Track[]> {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getRandomSongs', {
+      size,
+      ...genre && { genre },
+      ...fromYear && { fromYear },
+      ...toYear && { toYear },
+    })
+
+    return (response.randomSongs?.song || []).map(this.normalizeTrack, this)
+  }
+
+  async getFavourites() {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getStarred2')
+
+    return {
+      albums: (response.starred2?.album || []).map(this.normalizeAlbum, this),
+      artists: (response.starred2?.artist || []).map(this.normalizeArtist, this),
+      tracks: (response.starred2?.song || []).map(this.normalizeTrack, this)
+    }
+  }
+
+  async getRecentlyPlayedTracks(size = 200) {
+    this.checkInitialized()
+
+    const albums = await this.getAlbums('recently-played', size)
+
+    return albums.flatMap(a => a.tracks || [])
+  }
+
+  async addFavourite(id: string, type: 'track' | 'album' | 'artist') {
+    this.checkInitialized()
+
+    await this.fetch('/rest/star', {
+      id: type === 'track' ? id : undefined,
+      albumId: type === 'album' ? id : undefined,
+      artistId: type === 'artist' ? id : undefined,
+    })
+  }
+
+  async removeFavourite(id: string, type: 'track' | 'album' | 'artist') {
+    this.checkInitialized()
+
+    await this.fetch('/rest/unstar', {
+      id: type === 'track' ? id : undefined,
+      albumId: type === 'album' ? id : undefined,
+      artistId: type === 'artist' ? id : undefined,
+    })
+  }
+
+  async search (query: string, mode: SearchMode, size: number, offset?: number): Promise<SearchResult> {
+    this.checkInitialized()
+
+    const data = await this.fetch('/rest/search3', {
+      query,
+      albumCount: !mode || mode === 'album' ? size : 0,
+      artistCount: !mode || mode === 'artist' ? size : 0,
+      songCount: !mode || mode === 'track' ? size : 0,
+      albumOffset: offset ?? 0,
+      artistOffset: offset ?? 0,
+      songOffset: offset ?? 0,
+    })
+
+    return {
+      albums: (data.searchResult3.album || []).map(this.normalizeAlbum, this),
+      artists: (data.searchResult3.artist || []).map(this.normalizeArtist, this),
+      tracks: (data.searchResult3.song || []).map(this.normalizeTrack, this),
+    }
+  }
+
+  scan = async (): Promise<void> => {
+    this.checkInitialized()
+
+    return this.fetch('/rest/startScan')
+  }
+
+  async getScanStatus(): Promise<boolean> {
+    this.checkInitialized()
+
+    const response = await this.fetch('/rest/getScanStatus')
+    return response.scanStatus.scanning
+  }
+
+  async scrobble(id: string): Promise<void> {
+    this.checkInitialized()
+
+    try {
+      await this.fetch('/rest/scrobble', { id, submission: true })
+    } catch (err) {
+      if (err instanceof OfflineError) return
+      throw err
+    }
+  }
+
+  getDownloadUrl = (id: any): string => {
+    this.checkInitialized()
+
+    const url = new URL('/rest/download', this.serverUrl)
+    url.search = new URLSearchParams({
+      v: SubsonicApi.staticParams.v,
+      c: SubsonicApi.clientName,
+      ...this.auth,
+      id,
+    }).toString()
+
+    return url.toString()
+  }
+
+  getCoverArtUrl = (item: any): string | undefined => {
+    this.checkInitialized()
+
+    if (!item.coverArt)
+      return fallbackImage
+
+    const url = new URL('/rest/getCoverArt', this.serverUrl)
+
+    url.search = new URLSearchParams({
+      v: SubsonicApi.staticParams.v,
+      c: SubsonicApi.clientName,
+      ...this.auth,
+      size: (this.coverSize ?? 512).toString(),
+      id: item.coverArt,
+    }).toString()
+
+    return url.toString()
+  }
+
+  getStreamUrl = (id: any): string => {
+    this.checkInitialized()
+
+    const url = new URL('/rest/stream', this.serverUrl)
+
+    url.search = new URLSearchParams({
+      v: SubsonicApi.staticParams.v,
+      c: SubsonicApi.clientName,
+      ...this.auth,
+      format: this.streamFormat ?? 'raw',
+      maxBitRate: (this.streamBitrate ?? 0).toString(),
+      id,
+    }).toString()
+
+    return url.toString()
+  }
+
+  private normalizeTrack = (item: any): Track => ({
+    id: item.id,
+    title: item.title,
+    duration: item.duration,
+    size: item.size,
+    favourite: !!item.starred,
+    track: item.track,
+    album: item.album,
+    albumId: item.albumId,
+    artists: item.artists?.length
+      ? item.artists
+      : [{ id: item.artistId, name: item.artist }],
+    url: this.getStreamUrl(item.id),
+    image: this.getCoverArtUrl(item),
+    replayGain:
+      (Number.isFinite(item.replayGain?.trackGain) &&
+      Number.isFinite(item.replayGain?.albumGain) &&
+      item.replayGain?.trackPeak > 0 &&
+      item.replayGain?.albumPeak > 0)
+      ? item.replayGain
+      : null,
+  });
+
+  private normalizeGenres = (item: any): AlbumGenre[] => (
+    item.genres?.length ? item.genres : (
+      item.genre ? [{ name: item.genre }] : []
+    )
+  );
+
+  private normalizeAlbum = (item: any): Album => ({
+    id: item.id,
+    name: item.name,
+    description: (item.notes || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
+    artists:
+      item.artists?.length
+      ? item.artists
+      : [{ id: item.artistId, name: item.artist }],
+    image: this.getCoverArtUrl(item),
+    year: item.year || 0,
+    favourite: !!item.starred,
+    genres: this.normalizeGenres(item),
+    lastFmUrl: item.lastFmUrl,
+    musicBrainzUrl:
+      item.musicBrainzId
+      ? `https://musicbrainz.org/release/${item.musicBrainzId}`
+      : undefined,
+    tracks: (item.song || []).map(this.normalizeTrack, this),
+    releaseType: this.normalizeReleaseType(item),
+  });
+
+  private normalizeReleaseType = (item: any): string => {
+    if (item.isCompilation) return 'COMPILATION'
+    if (!item.releaseTypes?.length || item.releaseTypes[0] === '') return 'ALBUM'
+
+    const value = item.releaseTypes[0].toUpperCase()
+    return (['ALBUM', 'EP', 'SINGLE', 'COMPILATION'].includes(value)) ? value : startCase(item.releaseTypes[0].toLowerCase())
+  }
+
+  private normalizeArtist = (item: any): Artist => {
+    const
+      rawAlbums = item.album ? (Array.isArray(item.album) ? item.album : [item.album]) : [],
+      getAlbumTime = (a: any) => {
+        const released =
+          (a?.released)
+          ? Date.parse(a.released)
+          : NaN
+
+        if (!isNaN(released)) return released
+
+        const year = Number(a?.year)
+
+        if (!isNaN(year)) return new Date(year, 0, 1).getTime()
+
+        return Number.MIN_SAFE_INTEGER
+      },
+      sortedAlbums = [...rawAlbums].sort(
+        (a, b) => getAlbumTime(b) - getAlbumTime(a)
+      );
+
+    return {
+      id: item.id,
+      name: item.name,
+      description: (item.biography || '').replace(/<a[^>]*>.*?<\/a>/gm, ''),
+      genres: uniqBy(sortedAlbums.flatMap(this.normalizeGenres, this), 'name'),
+      albumCount: item.albumCount,
+      trackCount: rawAlbums.reduce((acc, a) => acc + (a.songCount || 0), 0),
+      favourite: !!item.starred,
+      lastFmUrl: item.lastFmUrl,
+      musicBrainzUrl:
+        item.musicBrainzId
+        ? `https://musicbrainz.org/artist/${item.musicBrainzId}`
+        : undefined,
+      albums: sortedAlbums.map(a => this.normalizeAlbum(a)),
+      similarArtist: (item.similarArtist || []).map(this.normalizeArtist, this),
+      topTracks: (item.topSongs || []).slice(0, 5).map(this.normalizeTrack, this),
+      image:
+        item.coverArt
+        ? this.getCoverArtUrl(item)
+        : item.artistImageUrl
+    }
+  }
+
+  private normalizePlaylist = (response: any): Playlist => ({
+    id: response.id,
+    name: response.name || '(Unnamed)',
+    comment: response.comment || '',
+    owner: response.owner || '',
+    createdAt: response.created || '',
+    updatedAt: response.changed || '',
+    trackCount: response.songCount,
+    duration: response.duration,
+    isPublic: response.public,
+    isReadOnly: false,
+    image:
+      (response.songCount > 0)
+      ? this.getCoverArtUrl(response)
+      : undefined,
+  })
+}
+
diff --git a/src/types.ts b/src/types.ts
@@ -0,0 +1,145 @@
+
+export type Auth = {
+  username: string
+  salt: string;
+  hash: string
+}
+
+export interface ServerInfo {
+  name: string
+  version: string
+  openSubsonic: boolean
+  extensions: string[]
+}
+
+export type StreamFormat = 'raw' | 'opus'
+export type StreamSettings = {
+  format: StreamFormat
+  bitrate: number
+}
+
+export enum ReplayGainMode {
+  None,   // No gain correction – play audio as-is
+  Track,  // Normalise each track independently
+  Album,  // Normalise relative to the full album (preserves dynamic contrast)
+  _Length // Sentinel value – used to cycle through modes with modulo
+}
+
+export type ReplayGain = {
+  trackGain: number // dB adjustment for a single track
+  trackPeak: number // Peak sample value for a single track (0–1)
+  albumGain: number // dB adjustment relative to the full album
+  albumPeak: number // Peak sample value for the full album
+}
+
+export type SearchMode = null | 'artist' | 'album' | 'track'
+
+export type AlbumSort =
+	'a-z' |
+	'recently-added'|
+	'recently-played' |
+	'most-played' |
+	'random'
+
+export interface AlbumArtist {
+  id: string,
+  name: string
+}
+
+export interface Track {
+  id: string
+  title: string
+  duration: number
+  size: number
+  favourite: boolean
+  image?: string
+  url?: string
+  track?: number
+  album?: string
+  albumId?: string
+  artists: AlbumArtist[]
+  isStream?: boolean
+  isPodcast?: boolean
+  isUnavailable?: boolean
+  playCount?: number
+  replayGain?: ReplayGain
+}
+
+export interface Genre {
+  id: string
+  name: string
+  albumCount: number
+  trackCount: number
+}
+export interface AlbumGenre {
+  name: string
+}
+
+export interface Album {
+  id: string
+  name: string
+  description?: string
+  artists: {name: string, id: string}[]
+  year: number
+  favourite: boolean
+  genres: AlbumGenre[]
+  image?: string
+  lastFmUrl?: string
+  musicBrainzUrl?: string
+  tracks?: Track[]
+  releaseType?: string
+}
+
+export interface Artist {
+  id: string
+  name: string
+  description?: string
+  genres: AlbumGenre[]
+  albumCount: number
+  trackCount: number
+  favourite: boolean
+  lastFmUrl?: string
+  musicBrainzUrl?: string
+  topTracks?: Track[]
+  similarArtist?: Artist[]
+  albums?: Album[]
+  image?: string
+}
+
+export interface SearchResult {
+  artists: Artist[]
+  albums: Album[]
+  tracks: Track[]
+}
+
+export interface Directory {
+  id: string
+  name: string
+  parent?: string
+  tracks: Track[]
+  directories: {
+    id: string
+    name: string
+  }[]
+}
+
+export interface Playlist {
+  id: string
+  name: string
+  comment: string
+  owner?: string
+  isPublic: boolean
+  isReadOnly: boolean
+  trackCount: number
+  duration: number
+  createdAt: string
+  updatedAt: string
+  image?: string
+  tracks?: Track[]
+}
+
+export interface PlayQueue {
+  tracks: Track[]
+  currentTrack: number
+  currentTrackPosition: number
+}
diff --git a/src/utils.ts b/src/utils.ts
@@ -0,0 +1,99 @@
+import type { Track } from './types'
+
+export const isMobile = () => (
+  matchMedia('(pointer: coarse)').matches 
+  && (navigator.maxTouchPoints > 0)
+)
+
+export const randomString = (): string => {
+  let arr = new Uint8Array(16)
+  window.crypto.getRandomValues(arr)
+  const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+  arr = arr.map(x => validChars.charCodeAt(x % validChars.length))
+  return String.fromCharCode.apply(null, Array.from(arr))
+}
+
+export const shuffle = (list: any[], moveFirst?: number): void => {
+  if (moveFirst !== undefined)
+    [list[0], list[moveFirst]] = [list[moveFirst], list[0]]
+
+  const
+    end = list.length - 1,
+    start = (
+      (moveFirst !== undefined)
+      ? 1
+      : 0
+    )
+
+  for (let i = end; i > start; i--) {
+    const j = Math.floor(Math.random() * (i - start + 1) + start);
+    [list[i], list[j]] = [list[j], list[i]]
+  }
+}
+
+export const shuffled = (list: any[], moveFirst?: number): any[] => {
+  list = [...list]
+  shuffle(list, moveFirst)
+  return list
+}
+
+export const trackListEquals = (a: Track[], b: Track[]): boolean => {
+  if (a.length !== b.length)
+    return false
+
+  for (let i = 0; i < a.length; i++) {
+    if (a[i]?.id !== b[i]?.id) return false
+  }
+
+  return true
+}
+
+export const formatArtists = (artists: { name: string }[]): string => artists.map(ar => ar.name).join(', ')
+
+export const formatTitle = (title: string): string => (
+  (!title)
+  ? ''
+  : (
+    (title.length > 40)
+    ? title.slice(0, 37) + '…'
+    : title
+  )
+)
+
+export const formatDuration = (value: number): string => {
+  if (!isFinite(value))
+    return '∞'
+
+  const
+    minutes = Math.floor(value / 60),
+    seconds = Math.floor(value % 60)
+
+  return `${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
+}
+
+export const formatBytes = (bytes: number, decimals: number): string => {
+  if (bytes === 0) return '0 Bytes';
+
+  const
+    k = 1024,
+    i = Math.floor(Math.log(bytes) / Math.log(k)),
+    unit = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'][i];
+
+  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals || 2))} ${unit}`;
+}
+
+export const sleep = async (ms: number) => new Promise(
+  resolve => setTimeout(resolve, ms)
+)
+
+export const isElementInViewport = (element: HTMLElement | null) => {
+  if (!element) return false
+
+  const rect = element.getBoundingClientRect()
+  return (
+    rect.bottom >= 0 &&
+    rect.right >= 0 &&
+    rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
+    rect.left <= (window.innerWidth || document.documentElement.clientWidth)
+  )
+}
diff --git a/src/view/About.vine.ts b/src/view/About.vine.ts
@@ -0,0 +1,31 @@
+import { ref } from 'vue'
+import { useMainStore } from '../store/main'
+
+export const AboutDialog = ({ visible }: { visible: boolean }) => {
+  vineEmits([ 'close' ])
+
+  const
+    mainStore = useMainStore(),
+    gitUrl = ref(import.meta.env.VITE_GIT_URL),
+    build = ref(''),
+    buildDate = ref('')
+
+  return vine`
+    <dialog v-if="visible" open="true" @click="$emit('close')">
+      <article @click.stop>
+        <h2>{ insert logo here }</h2>
+        <a :href="gitUrl" target="_blank" rel="noopener noreferrer">{{ gitUrl }}</a>
+        <p>Licensed under the AGPLv3 license.</p>
+        <div>Build: {{ build }}</div>
+        <div>Build date: {{ buildDate }}</div>
+        <div>Server name: {{ mainStore.serverInfo.name }}</div>
+        <div>Server version: {{ mainStore.serverInfo.version }}</div>
+        <div>
+          Server URL:
+          <a :href="mainStore.serverUrl" target="_blank" rel="noopener noreferrer">{{ mainStore.serverUrl }}</a>
+        </div>
+        <div>OpenSubsonic extensions: {{ mainStore.serverInfo.extensions.join(', ') }}</div>
+      </article>
+    </dialog>
+  `
+}+
\ No newline at end of file
diff --git a/src/view/Album.vine.ts b/src/view/Album.vine.ts
@@ -0,0 +1,252 @@
+import { ref, computed, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+
+import type { Album, AlbumSort } from '../types'
+
+import { useSubsonicApi } from '../subsonicApi'
+import { useFavouriteStore } from '../store/favourite'
+import { usePlayerStore } from '../store/player'
+import { useCacheStore } from '../store/cache'
+import { useMainStore } from '../store/main'
+import { useRadioStore } from '../store/radio'
+
+import { AlbumList } from '../components/AlbumList.vine'
+import { TrackList } from '../components/TrackList.vine'
+
+export const AlbumView = (props: { id?: string | null, sort?: AlbumSort }) => {
+  const
+    subsonicApi = useSubsonicApi(),
+
+    router = useRouter(),
+    route = useRoute(),
+
+    mainStore = useMainStore(),
+    favouriteStore = useFavouriteStore(),
+    playerStore = usePlayerStore(),
+    cacheStore = useCacheStore(),
+    radioStore = useRadioStore(),
+
+    sort = ref<AlbumSort>(props.sort ?? 'recently-added'),
+    albums = ref<Album[]>([]),
+    album = ref<Album | null>(null),
+    cached = ref<boolean>(false),
+    hasMoreAlbums = ref<boolean>(true),
+
+    isFavourite = computed(() => (!!album.value && favouriteStore.get('album', album.value.id))),
+    isPlaying = computed(() => playerStore.isPlaying),
+    sortOptions = [
+      [ 'recently-added', 'Recently added'],
+      [ 'most-played', 'Most played' ],
+      [ 'recently-played', 'Recently played' ],
+      [ 'random', 'Random' ],
+      [ 'a-z', 'Alphabetically' ],
+    ],
+
+    fetchAlbums = async() => {
+      try {
+        mainStore.isLoading = true
+
+        const response = await subsonicApi.getAlbums(sort.value, 30, albums.value.length)
+        albums.value.push(...response)
+        hasMoreAlbums.value = response.length >= 30
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    fetchAlbum = async() => {
+      if (!props.id)
+        return
+
+      try {
+        mainStore.isLoading = true
+        album.value = await subsonicApi.getAlbumDetails(props.id)
+
+        if (album.value)
+          cached.value = await cacheStore.isCached(album.value)
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    playNow = () => {
+      if (!album.value)
+        return
+
+      const
+        currentTrack = playerStore.track,
+        isAlbumTrack =
+          !!currentTrack && (
+            currentTrack.albumId === album.value.id
+            || album.value.tracks?.some(t => t.id === currentTrack.id)
+          )
+
+      if (isAlbumTrack)
+        return playerStore.playPause()
+
+      if (album.value.tracks?.length)
+        return playerStore.playNow(album.value.tracks)
+    },
+
+    setNextInQueue = () => (
+      (album.value)
+      ? playerStore.setNextInQueue(album.value.tracks!)
+      : undefined
+    ),
+
+    addToQueue = () => (
+      (album.value)
+      ? playerStore.addToQueue(album.value.tracks!)
+      : undefined
+    ),
+
+    toggleFavourite = async () => (!!album.value && favouriteStore.toggle('album', album.value.id)),
+
+    cacheAlbum = async() => {
+      if (!album.value)
+        return 
+
+      await cacheStore.cacheAlbum(album.value)
+      cached.value = true
+    },
+
+    clearAlbumCache = async() => {
+      if (!album.value)
+        return
+
+      await cacheStore.clearAlbumCache(album.value)
+      cached.value = false
+
+      await fetchAlbum()
+    },
+
+    shuffleNow = async() => {
+      if (!album.value?.tracks?.length)
+        return
+
+      await radioStore.shuffleAlbum(album.value.tracks)
+    },
+
+    radioNow = async() => {
+      if (!album.value?.tracks?.length || !album.value?.artists?.length)
+        return
+
+      await radioStore.radioAlbum(album.value.tracks, album.value.artists)
+    }
+
+  watch(
+    () => [ sort.value, props.id ],
+    () => {
+      if (props.id) {
+        fetchAlbum()
+      } else {
+        albums.value = []
+        hasMoreAlbums.value = true
+        fetchAlbums()
+      }
+    },
+    { immediate: true }
+  )
+
+  return vine`
+    <template v-if="!id">
+      <div class="row align-items-center justify-content-space-between">
+        <span class="main-title">
+          <Icon icon="albums" />
+          Albums
+        </span>
+        <div>
+          <select v-model="sort" name="sort" title="Sort by...">
+            <option v-for="option in sortOptions" :value="option[0]">{{ option[1] }}</option>
+          </select>
+        </div>
+      </div>
+
+      <AlbumList :items="albums" tile-size="200" />
+      <InfiniteLoader :is-loading="mainStore.isLoading" :has-more="hasMoreAlbums" @load-more="fetchAlbums" />
+    </template>
+  
+    <template v-else>
+      <Header :image="album.image" :hover="'Play/Pause'" @click="playNow">
+        <small class="noselect">Album</small>
+        <h1>
+          {{ album.name }}
+          <template v-if="album.lastFmUrl || album.musicBrainzUrl">
+            <a role="button" v-if="album.lastFmUrl" :href="album.lastFmUrl" target="_blank" rel="noopener noreferrer" data-tooltip="Last.fm">
+              <Icon icon="lastfm" />
+            </a>
+            <a role="button" v-if="album.musicBrainzUrl" :href="album.musicBrainzUrl" target="_blank" rel="noopener noreferrer" data-tooltip="MusicBrainz">
+              <Icon icon="musicbrainz" />
+            </a>
+          </template>
+        </h1>
+        <div class="row align-items-center">
+          <div>
+            by
+            <span v-for="(artist, index) in album.artists" :key="artist.id">
+              <span v-if="index > 0">, </span>
+              <router-link :to="{name: 'artist', params: { id: artist.id }}">
+                {{ artist.name }}
+              </router-link>
+            </span>
+          </div>
+
+          <div v-if="album.year">
+            <span class="seperator" />
+            {{ album.year }}
+          </div>
+
+          <template v-if="album.genres.length">
+            <span class="seperator" />
+            <span v-for="({ name: genre }, index) in album.genres" :key="genre">
+              <span v-if="index > 0">, </span>
+              <router-link :to="{name: 'genre', params: { id: genre }}">
+                {{ genre }}
+              </router-link>
+            </span>
+          </template>
+        </div>
+
+        <div class="scroll" v-if="album.description">
+          {{ album.description }}
+        </div>
+
+        <div class="row text-nowrap">
+          <button class="contrast" @click="playNow">
+            <Icon icon="play" /> Play
+          </button>
+
+          <button data-tooltip="Shuffle album" @click="shuffleNow">
+            <Icon icon="shuffle" />
+          </button>
+
+          <button data-tooltip="Album Radio" @click="radioNow">
+            <Icon icon="radio" /> 
+          </button>
+
+          <button data-tooltip="Favourite album" @click="toggleFavourite">
+            <Icon :icon="isFavourite ? 'heart-fill' : 'heart'" />
+          </button>
+
+          <OverflowMenu>
+            <DropdownItem icon="play" @click="setNextInQueue">
+              Next
+            </DropdownItem>
+            <DropdownItem icon="queue" @click="addToQueue">
+              Queue
+            </DropdownItem>
+            <DropdownItem v-if="!cached" icon="cache" @click="cacheAlbum">
+              Cache
+            </DropdownItem>
+            <DropdownItem v-if="cached" icon="trash" @click="clearAlbumCache">
+              Refresh Cache
+            </DropdownItem>
+          </OverflowMenu>
+        </div>
+      </Header>
+      <TrackList :tracks="album.tracks || []" hide-album hide-cover />
+      <EmptyIndicator v-if="!mainStore.isLoading && !album" label="Album not found"/>
+    </template>
+
+  `
+}
diff --git a/src/view/Artist.vine.ts b/src/view/Artist.vine.ts
@@ -0,0 +1,281 @@
+import { ref, computed, watch } from 'vue'
+import { groupBy, orderBy } from 'lodash-es'
+
+import type { Artist, Album, Track } from '../types'
+
+import { useSubsonicApi } from '../subsonicApi'
+import { useMainStore } from '../store/main'
+import { useFavouriteStore } from '../store/favourite'
+import { useRadioStore } from '../store/radio'
+import { usePlayerStore } from '../store/player'
+
+import { ArtistList } from '../components/ArtistList.vine'
+import { AlbumList } from '../components/AlbumList.vine'
+import { TrackList } from '../components/TrackList.vine'
+
+export const ArtistView = ({ id = null, tracksView = false }: { id?: string | null, tracksView?: boolean }) => {
+  let
+    generator: AsyncGenerator | null = null
+
+  const
+    subsonicApi = useSubsonicApi(),
+
+    mainStore = useMainStore(),
+    favouriteStore = useFavouriteStore(),
+    playerStore = usePlayerStore(),
+    radioStore = useRadioStore(),
+
+    hasMore = ref<boolean>(false),
+    isFavourite = ref<boolean>(false),
+    sort = ref<string>('albums'),
+    artists = ref<Artist[]>([]),
+    artist = ref<Artist>(),
+    albums = ref<{ releaseType: string, albums: Album[] }[]>(),
+    tracks = ref<Track[]>([]),
+
+    toggleAlbumSortOrder = () => mainStore.toggleArtistAlbumSortOrder(),
+    toggleFavourite = () => {
+      if (!id)
+        return 
+
+      favouriteStore.toggle('artist', id)
+      isFavourite.value = favouriteStore.get('artist', id)
+    },
+
+    playNow = () => (!!artist.value?.topTracks && playerStore.playNow(artist.value.topTracks)),
+    shuffleNow = () => (!!id && radioStore.shuffleArtist(id)),
+    radioNow = () => (!!id && radioStore.radioArtist(id)),
+
+    formatReleaseType = (value: string) => {
+      switch (value.toUpperCase()) {
+      case 'ALBUM': return 'Albums'
+      case 'EP': return 'EPs'
+      case 'SINGLE': return 'Singles'
+      case 'COMPILATION': return 'Compilations'
+      default: return value
+      }
+    },
+
+    fetchArtists = async () => {
+      try {
+        mainStore.isLoading = true
+        artists.value = []
+
+        const response = await subsonicApi.getArtists()
+
+        if (sort.value === 'a-z') {
+          artists.value = orderBy(response, 'name')
+        } else {
+          artists.value = orderBy(response, 'albumCount', 'desc')
+        }
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    fetchArtist = async() => {
+      if (!id)
+        return 
+
+      try {
+        mainStore.isLoading = true
+        const
+          artistResponse = await subsonicApi.getArtistDetails(id),
+          groupOrder = ['ALBUM', 'EP', 'SINGLE']
+     
+        artist.value = artistResponse,
+        isFavourite.value = favouriteStore.get('artist', id),
+        albums.value = (
+          Object.entries(
+            groupBy(
+              orderBy(
+                artistResponse?.albums ?? [],
+                [ 'year', 'name' ],
+                [ mainStore.artistAlbumSortOrder, mainStore.artistAlbumSortOrder ]
+              ),
+              'releaseType'
+            )
+          ).sort(
+            ([aType], [bType]) => {
+              const [a, b] = [ groupOrder.indexOf(aType), groupOrder.indexOf(bType) ]
+
+              return (
+                (a === -1 && b === -1)
+                ? 0
+                : (
+                  (a === -1)
+                  ? 1
+                  : (
+                    (b === -1)
+                    ? -1
+                    : a - b
+                  )
+                )
+              )
+            }
+          ).map(
+            ([releaseType, albums]) => ({
+              releaseType,
+              albums: albums || [],
+            } as { releaseType: string, albums: Album[] })
+          )
+        )
+      } catch (error) {
+        console.log(error)
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    loadMoreTracks = async () => {
+      if (!id)
+        return 
+
+      try {
+
+        if (generator === null) {
+          const subsonicApi = useSubsonicApi()
+          generator = await subsonicApi.getTracksByArtist(id)
+        }
+
+        mainStore.isLoading = true
+        const { value, done } = await generator.next()
+
+        if (value !== undefined)
+          tracks.value.push(...value)
+
+        hasMore.value = !done
+      } finally {
+        mainStore.isLoading = false
+      }
+    }
+
+  watch(
+    () => [ id, sort.value ],
+    () => (
+      (!id)
+      ? fetchArtists()
+      : fetchArtist()
+    ),
+    { immediate: true }
+  )
+
+  return vine`
+    <template v-if="!id">
+      <div class="row align-items-center justify-content-space-between">
+        <span class="main-title flex-grow">
+          <Icon icon="artists" />
+          Artists
+        </span>
+        <div>
+          <select v-model="sort" name="sort" title="Sort by...">
+            <option value="albums">Most albums</option>
+            <option value="a-z">Alphabetically</option>
+          </select>
+        </div>
+      </div>
+      <ArtistList :items="artists" tile-size="200" />
+    </template>
+  
+    <template v-if="artist">
+      <Header :image="artist.image" :hover="'Play/Pause'" @click="shuffleNow">
+        <small class="noselect">Artist</small>
+        <h1>
+          {{ artist.name }}
+          <template v-if="artist.lastFmUrl || artist.musicBrainzUrl">
+            <a v-if="artist.lastFmUrl" role="button" :href="artist.lastFmUrl" target="_blank" rel="noopener noreferrer" data-tooltip="Last.fm">
+              <Icon icon="lastfm" />
+            </a>
+            <a v-if="artist.musicBrainzUrl" role="button" :href="artist.musicBrainzUrl" target="_blank" rel="noopener noreferrer" data-tooltip="MusicBrainz">
+              <Icon icon="musicbrainz" />
+            </a>
+          </template>
+        </h1>
+
+        <div class="row align-items-center">
+          <span><strong>{{ artist.albumCount }}</strong> albums</span>
+          <span class="seperator" />
+          <span><strong>{{ artist.trackCount }}</strong> tracks</span>
+
+          <template v-if="artist.genres.length > 0">
+            <span class="seperator" />
+            <span v-for="({ name: genre }, index) in artist.genres" :key="genre">
+              <span v-if="index > 0">, </span>
+              <router-link :to="{name: 'genre', params: { id: genre }}">
+                {{ genre }}
+              </router-link>
+            </span>
+          </template>
+        </div>
+
+        <div class="scroll" v-if="artist.description">
+          {{ artist.description }}
+        </div>
+
+        <div class="row text-nowrap">
+          <button class="contrast" v-if="artist.trackCount > 0" data-tooltip="Play Artist Top Tracks" @click="playNow">
+            <Icon icon="play" />
+            Play
+          </button>
+          <button v-if="artist.trackCount > 0" data-tooltip="Artist Shuffle" @click="shuffleNow">
+            <Icon icon="shuffle" />
+          </button>
+          <button v-if="artist.trackCount > 0" data-tooltip="Artist Radio" @click="radioNow">
+            <Icon icon="radio" />
+          </button>
+          <button v-if="artist.trackCount > 0" data-tooltip="Favourite Artist" @click="toggleFavourite">
+            <Icon :icon="isFavourite ? 'heart-fill' : 'heart'" />
+          </button>
+        </div>
+      </Header>
+
+      <template v-if="!tracksView">
+        <template v-if="artist.topTracks.length > 0">
+          <div class="row align-items-center align-content-center">
+            <span class="section-title flex-grow">
+              Top tracks
+            </span>
+            <router-link :to="{ name: 'artist-tracks', params: { id } }">
+              View all
+            </router-link>
+          </div>
+          <TrackList :tracks="artist.topTracks" hide-artist />
+        </template>
+
+        <div v-for="({ releaseType, albums: releaseTypeAlbums }) in albums" :key="releaseType">
+          <div class="row align-items-center">
+            <span class="section-title flex-grow">
+              <Icon icon="albums" />
+              {{ formatReleaseType(releaseType) }} <small>({{ releaseTypeAlbums.length }})</small>
+            </span>
+            <button @click="toggleAlbumSortOrder">
+              <Icon icon="sort" />
+            </button>
+          </div>
+          <AlbumList :items="releaseTypeAlbums">
+            <template #text="{ year }">
+              {{ year || 'Unknown' }}
+            </template>
+          </AlbumList>
+        </div>
+
+        <template v-if="artist.similarArtist.length > 0">
+          <div class="row align-items-center">
+            <span class="section-title">
+              <Icon icon="artists" />
+              Similar artists
+            </span>
+          </div>
+          <ArtistList :items="artist.similarArtist" allow-h-scroll />
+        </template>
+      </template>
+
+      <template v-else>
+        <TrackList v-if="tracks.length > 0" :tracks="tracks" />
+        <InfiniteLoader :is-loading="mainStore.isLoading" :has-more="hasMore" @load-more="loadMore" />
+      </template>
+    </template>
+
+    <EmptyIndicator v-if="!mainStore.isLoading && (!artist || !artists.length)" />
+  `
+}
diff --git a/src/view/Discover.vine.ts b/src/view/Discover.vine.ts
@@ -0,0 +1,240 @@
+import { ref, computed, onMounted, watch } from 'vue'
+import { orderBy } from 'lodash-es'
+
+import type { Album, AlbumGenre, Genre, Artist, Playlist } from '../types'
+
+import { useSubsonicApi } from '../subsonicApi'
+
+import { reloadToken } from '../reload'
+import { useMainStore } from '../store/main'
+import { useRadioStore } from '../store/radio'
+
+import { AlbumList } from '../components/AlbumList.vine'
+import { PlaylistList } from '../components/PlaylistList.vine'
+import { ArtistList } from '../components/ArtistList.vine'
+
+export const DiscoverView = () => {
+  const
+    subsonicApi = useSubsonicApi(),
+
+    mainStore = useMainStore(),
+    radioStore = useRadioStore(),
+
+    lastGenre = ref<AlbumGenre | null>(null),
+    result = ref({
+      genres: [] as Genre[],
+      mood: [] as Album[],
+      favartists: [] as Artist[],
+      favalbums: [] as Album[],
+      playlists: [] as Playlist[],
+      played: [] as Album[],
+      most: [] as Album[],
+      recent: [] as Album[],
+      random: [] as Album[],
+    }),
+
+    empty = computed(() => Object.values(result.value).findIndex(x => x.length > 0) === -1),
+
+    clearState = () => Object.assign(result.value, {
+      recent: [],
+      played: [],
+      mood: [],
+      most: [],
+      favalbums: [],
+      favartists: [],
+      genres: [],
+      playlists: [],
+      random: [],
+    }),
+
+    fetchData = async () => {
+      if (mainStore.isLoading)
+        return
+
+      try {
+        mainStore.isLoading = true
+
+        subsonicApi.getGenres()
+          .then((genres: Genre[]) => {
+            const genreNames = genres.map((genre: Genre) => ({ ...genre, id: genre.name }))
+            result.value.genres = orderBy(genreNames, 'albumCount', 'desc')
+          })
+
+        subsonicApi.getFavourites()
+          .then(favourites => {
+            result.value.favartists = favourites.artists.slice(0, 16)
+            result.value.favalbums = favourites.albums.slice(0, 16)
+          })
+
+        subsonicApi.getAlbums('recently-played', 24)
+          .then(async played => {
+            result.value.played = played
+            if (played.length === 0) return
+
+            const lastPlayed = played[0]
+            lastGenre.value = (lastPlayed as Album).genres[0]!
+            if (!lastGenre.value) return
+            const shuffled = true
+            const albumsByGenre = await subsonicApi.getAlbumsByGenre(lastGenre.value.name, 32, 0, shuffled)
+            result.value.mood = albumsByGenre
+          })
+
+        subsonicApi.getAlbums('recently-added', 32)
+          .then(recent => {
+            result.value.recent = recent
+          })
+
+        subsonicApi.getPlaylists()
+          .then(playlists => {
+            result.value.playlists = playlists.slice(0, 10)
+          })
+
+        subsonicApi.getAlbums('random', 24)
+          .then(random => {
+            result.value.random = random
+          })
+
+        subsonicApi.getAlbums('most-played', 24)
+          .then(most => {
+            result.value.most = most
+          })
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    radioRecentlyPlayed = () => radioStore.shuffleRecentlyPlayed(),
+    radioMostPlayed = () => radioStore.shuffleMostPlayed(),
+    radioFavouriteAlbums = () => radioStore.shuffleFavouriteAlbums(),
+    radioFavouriteArtists = () => radioStore.shuffleFavouriteArtists(),
+    radioRecentlyAdded = () => radioStore.shuffleRecentlyAdded(),
+    luckyRadio = () => radioStore.luckyRadio(),
+    radioMood = () => (
+      (lastGenre.value)
+      ? radioStore.shuffleMood(lastGenre.value.name)
+      : undefined
+    )
+
+  onMounted(fetchData)
+
+  watch(
+    reloadToken,
+    () => (clearState() && fetchData())
+  )
+
+  return vine`
+    <div v-if="result.genres.length" class="genres scroll noselect">
+      <router-link
+        v-for="item in result.genres"
+        :key="item.id"
+        :to="{ name: 'genre', params: { id: item.id } }"
+      >
+        {{ item.name }}
+      </router-link>
+    </div>
+
+    <div v-if="result.mood.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'genre', params: { id: lastGenre.name } }" class="section-title">
+          <Icon icon="genres" />
+          Current mood
+        </router-link>
+        <button data-tooltip="Current Mood Radio" @click="radioMood()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <AlbumList :items="result.mood" tile-size="75" allow-h-scroll two-rows />
+    </div>
+
+    <div v-if="result.favalbums.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'favourites' }" class="section-title">
+          <Icon icon="heart" />
+          Favurited albums
+        </router-link>
+        <button data-tooltip="Favourite Albums Radio" @click="radioFavouriteAlbums()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <AlbumList :items="result.favalbums" allow-h-scroll />
+    </div>
+
+    <div v-if="result.favartists.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'favourites', params: { section: 'artists' } }" class="section-title">
+          <Icon icon="heart-artist" />
+          Favurited artists
+        </router-link>
+        <button data-tooltip="Favourite Artists Radio" @click="radioFavouriteArtists()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <ArtistList :items="result.favartists" allow-h-scroll />
+    </div>
+
+    <div v-if="result.playlists.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'playlists' }" class="section-title">
+          <Icon icon="playlist" />
+          Playlists
+        </router-link>
+        <button data-tooltip="Global Radio" @click="luckyRadio()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <PlaylistList :items="result.playlists" allow-h-scroll />
+    </div>
+
+    <div v-if="result.most.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'albums', params: { sort: 'most-played' } }" class="section-title">
+          <Icon icon="chart" />
+          Most Played
+        </router-link>
+        <button data-tooltip="Most Played Radio" @click="radioMostPlayed()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <AlbumList :items="result.most" allow-h-scroll />
+    </div>
+
+    <div v-if="result.played.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'albums', params: { sort: 'recently-played' } }" class="section-title">
+          <Icon icon="recent" />
+          Recently played
+        </router-link>
+        <button data-tooltip="Recently Played Radio" @click="radioRecentlyPlayed()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <AlbumList :items="result.played" allow-h-scroll />
+    </div>
+
+    <div v-if="result.recent.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'albums', params: { sort: 'recently-added' } }" class="section-title">
+          <Icon icon="note-plus" />
+          Recently added
+        </router-link>
+        <button data-tooltip="Recently added Radio" @click="radioRecentlyAdded()">
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <AlbumList :items="result.recent" allow-h-scroll />
+    </div>
+
+    <div v-if="result.random.length">
+      <div class="row align-items-center justify-content-space-between">
+        <router-link :to="{ name: 'albums', params: { sort: 'random' } }" class="section-title">
+          <Icon icon="random" />
+          Get lucky
+        </router-link>
+        <button data-tooltip="Random play" @click="luckyRadio()"  >
+          <Icon icon="radio" />
+        </button>
+      </div>
+      <AlbumList :items="result.random" tile-size="75" allow-h-scroll two-rows />
+    </div>
+  `
+}
diff --git a/src/view/Favourites.vine.ts b/src/view/Favourites.vine.ts
@@ -0,0 +1,76 @@
+import { ref, watch } from 'vue'
+
+import type { Artist, Album, Track } from '../types'
+import { useSubsonicApi } from '../subsonicApi'
+
+import { useMainStore } from '../store/main'
+import { useFavouriteStore } from '../store/favourite'
+
+import { AlbumList } from '../components/AlbumList.vine'
+import { ArtistList } from '../components/ArtistList.vine'
+import { TrackList } from '../components/TrackList.vine'
+
+export const FavouritesView = ({ section = 'tracks' }: { section?: string }) => {
+  const
+    api = useSubsonicApi(),
+    favouriteStore = useFavouriteStore(),
+
+    state = ref< null | {
+      artists: Artist[],
+      albums: Album[],
+      tracks: Track[],
+    }>(null),
+
+    fetchFavourites = async () => {
+      const result = await api.getFavourites()
+
+      state.value = {
+        artists: result?.artists.filter((artist: Artist) => favouriteStore.artists[artist.id]),
+        albums: result?.albums.filter((album: Album) => favouriteStore.albums[album.id]),
+        tracks: result?.tracks.filter((track: Track) => favouriteStore.tracks[track.id]),
+      }
+    }
+
+  watch(
+    favouriteStore,
+    fetchFavourites,
+    { deep: true, immediate: true }
+  )
+
+  return vine`
+    <div class="row align-items-center justify-content-space-between">
+      <span class="main-title">
+        <Icon icon="heart-fill" />
+        Favourites
+      </span>
+      <nav>
+        <router-link role="button" :to="{... $route, params: { section: 'tracks' }}">
+          <Icon icon="tracks" /> Tracks
+        </router-link>
+        <router-link role="button" :to="{... $route, params: { section: 'albums' }}">
+          <Icon icon="albums" /> Albums
+        </router-link>
+        <router-link role="button" :to="{... $route, params: { section: 'artists' }}">
+          <Icon icon="artists" /> Artists
+        </router-link>
+      </nav>
+    </div>
+
+    <template v-if="state">
+      <template v-if="section === 'tracks'">
+        <TrackList v-if="state.tracks.length > 0" :tracks="state.tracks" />
+        <EmptyIndicator v-else />
+      </template>
+
+      <template v-if="section === 'albums'">
+        <AlbumList v-if="state.albums.length > 0" :items="state.albums" tile-size="200" />
+        <EmptyIndicator v-else />
+      </template>
+
+      <template v-if="section === 'artists'">
+        <ArtistList v-if="state.artists.length > 0" :items="state.artists" tile-size="200" />
+        <EmptyIndicator v-else />
+      </template>
+    </template>
+  `;
+}+
\ No newline at end of file
diff --git a/src/view/Genre.vine.ts b/src/view/Genre.vine.ts
@@ -0,0 +1,157 @@
+import { ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { orderBy } from 'lodash-es'
+
+import type { Album, Genre } from '../types'
+import { useSubsonicApi } from '../subsonicApi'
+
+import { useMainStore } from '../store/main'
+import { usePlayerStore } from '../store/player'
+import { useRadioStore } from '../store/radio'
+
+import { AlbumList } from '../components/AlbumList.vine'
+
+interface GenreWithAlbums {
+  id: string
+  name: string
+  albumCount: number
+  albums: Album[]
+}
+
+export const GenreView = ({ id }: { id: string }) => {
+  const
+    route = useRoute(),
+    subsonicApi = useSubsonicApi(),
+
+    mainStore = useMainStore(),
+    playerStore = usePlayerStore(),
+    radioStore = useRadioStore(),
+
+    sort = ref<string>('a-z'),
+    plainGenres = ref<Genre[]>([]),
+    genres = ref<GenreWithAlbums[]>([]),
+    albums = ref<Album[]>([]),
+    hasMore = ref<boolean>(true),
+
+    shuffleNow = () => (!!id && radioStore.shuffleGenre(id)),
+
+    fetchGenres = async () => {
+      mainStore.isLoading = true
+      genres.value = []
+
+      try {
+        const response = await subsonicApi.getGenres()
+        plainGenres.value = (
+          (sort.value !== 'a-z')
+          ? orderBy(response, 'albumCount', 'desc')
+          : orderBy(response, 'name')
+        )
+
+        fetchGenreAlbums()
+      } catch (error) {
+        console.error('Failed to load genres or albums:', error)
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    fetchGenreAlbums = async () => {
+      try {
+        const numCurrentItems = genres.value.length
+
+        mainStore.isLoading = true
+
+        Promise.all(
+          plainGenres.value.slice(
+            numCurrentItems,
+            numCurrentItems + 5
+          )
+          .map(async (genre: Genre) => {
+            return {
+              id: genre.id,
+              name: genre.name,
+              albumCount: genre.albumCount,
+              albums: await subsonicApi.getAlbumsByGenre(genre.id, 20, 0, true),
+            } as GenreWithAlbums
+          })
+        ).then(
+          result => genres.value.push(...result)
+        )
+
+        hasMore.value = genres.value.length < plainGenres.value.length
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    fetchAlbums = async() => {
+      try {
+        mainStore.isLoading = true
+
+        const newAlbums = await subsonicApi.getAlbumsByGenre(id, 30, albums.value.length)
+
+        albums.value.push(...newAlbums)
+        hasMore.value = !!newAlbums.length
+      } catch (error) {
+        console.log(error)
+      } finally {
+        mainStore.isLoading = false
+      }
+    }
+
+  watch(
+    () => [ id, sort.value ],
+    async () => {
+      if (!id) {
+        fetchGenres()
+      } else {
+        albums.value = []
+        fetchAlbums()
+      }
+    },
+    { immediate: true }
+  )
+
+  return vine`
+    <template v-if="!id">
+      <div class="row align-items-center justify-content-space-between">
+        <span class="main-title">
+          <Icon icon="genres" />
+          Genres
+        </span>
+        <div>
+          <select v-model="sort" name="sort" title="Sort by...">
+            <option value="most">Most albums</option>
+            <option value="a-z">Alphabetically</option>
+          </select>
+        </div>
+      </div>
+
+      <div v-for="genre in genres" :key="genre.id">
+        <div class="row align-items-center justify-content-space-between">
+            <router-link class="section-title" :to="{ name: 'genre', params: { id: genre.id } }">
+              {{ genre.name }} - <small>{{ genre.albumCount }} Albums</small>
+            </router-link>
+          <button data-tooltip="Genre Radio" @click="radio.shuffleGenre(genre.id)">
+            <Icon icon="radio" />
+          </button>
+        </div>
+        <AlbumList :items="genre.albums" tile-size="100" two-rows allow-h-scroll />
+      </div>
+
+      <InfiniteLoader :is-loading="mainStore.isLoading" :has-more="hasMore" @load-more="fetchGenreAlbums" />
+    </template>
+
+    <template v-else>
+      <div class="row align-items-center">
+        <div class="main-title flex-grow">{{ id }}</div>
+        <button class="title-color" data-tooltip="Genre Radio" @click="shuffleNow">
+          <Icon icon="radio" />
+        </button>
+      </div>
+
+      <AlbumList :items="albums" />
+      <InfiniteLoader :is-loading="mainStore.isLoading" :has-more="hasMore" @load-more="fetchAlbums" />
+    </template>
+  `
+}+
\ No newline at end of file
diff --git a/src/view/Login.vine.ts b/src/view/Login.vine.ts
@@ -0,0 +1,114 @@
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+import { useMainStore } from '../store/main'
+import { SubsonicApi, useSubsonicApi } from '../subsonicApi'
+
+import { Logo } from '../components/Logo.vine'
+
+export const LoginView = ({ returnTo = '/discover' }: { returnTo?: string }) => {
+  const
+    router = useRouter(),
+    mainStore = useMainStore(),
+    subsonicApi = useSubsonicApi(),
+
+    staticServerUrl = import.meta.env.SERVER_URL ?? null,
+
+    serverUrl = ref<string>(mainStore.serverUrl ?? ''),
+    username = ref<string>(mainStore.serverCredentials?.username ?? ''),
+    password = ref<string>(''),
+
+    error = ref<Error | null>(null),
+    displayForm = ref<boolean>(false),
+    hasError = computed(() => error.value !== null),
+
+    loginHandler = async() => {
+      try {
+        mainStore.isLoading = true
+        error.value = null
+        const auth = SubsonicApi.createAuth(username.value, password.value)
+
+        subsonicApi.setServerUrl(staticServerUrl ?? serverUrl.value)
+        subsonicApi.setAuth(auth)
+        subsonicApi.initialize()
+
+        const serverInfo = await subsonicApi.fetchServerInfo()
+
+        mainStore.setServerUrl(staticServerUrl ?? serverUrl.value)
+        mainStore.setServerCredentials(auth)
+        mainStore.setServerInfo(serverInfo)
+
+        router.replace(returnTo)
+      } catch (err: any) {
+        console.error(err)
+
+        mainStore.isLoading = false
+        error.value = err
+      }
+    }
+
+  onMounted(
+    () => {
+      if (!mainStore.isAuthenticated()) {
+        displayForm.value = true
+      } else {
+        router.replace(returnTo)
+      }
+    }
+  )
+
+  return vine`
+    <div v-if="!displayForm" class="row justify-content-center">
+      <span :aria-busy="true" />
+    </div>
+    <fieldset v-else class="login" :disabled="mainStore.isLoading">
+      <form @submit.prevent="loginHandler">
+        <div class="logo">
+          <Logo />
+        </div>
+
+        <label v-if="!staticServerUrl">
+          Server
+          <input
+            type="text"
+            name="serverUrl"
+            v-model.trim="serverUrl"
+            :class="{'is-invalid': hasError}"
+          >
+        </label>
+
+        <label>
+          Username
+          <input
+            type="text"
+            name="username"
+            v-model="username"
+            :class="{'is-invalid': hasError}"
+          >
+        </label>
+
+        <label>
+          Password
+          <input
+            type="password"
+            name="password"
+            v-model="password"
+            :class="{'is-invalid': hasError}"
+          >
+        </label>
+
+        <div v-if="error != null" class="alert">
+          <span class="bold">Error:</span>
+          {{ error.message }}
+        </div>
+
+        <button :disabled="mainStore.isLoading">
+          <span :aria-busy="mainStore.isLoading"/> Log in
+        </button>
+        <div class="row justify-content-center">
+          <a href="/app/">NavidromeUI</a>
+        </div>
+      </form>
+    </fieldset>
+  `;
+}+
\ No newline at end of file
diff --git a/src/view/PlayerQueue.vine.ts b/src/view/PlayerQueue.vine.ts
@@ -0,0 +1,87 @@
+import { ref, computed, watch } from 'vue'
+
+import type { Track } from '../types'
+
+import { usePlayerStore } from '../store/player'
+
+import { TrackList } from '../components/TrackList.vine'
+import { 
+  type ConfirmDialogExpose,
+  ConfirmDialog
+} from '../components/ConfirmDialog.vine'
+
+export const PlayerQueueView = () => {
+  const
+    playerStore = usePlayerStore(),
+
+    confirmDialog = ref<ConfirmDialogExpose | null>(null),
+
+    tracks = computed(() => playerStore.queue),
+    queueIndex = computed(() => playerStore.queueIndex),
+
+    remove = (index: number) => playerStore.removeFromQueue(index),
+    shuffle = () => playerStore.shuffleQueue(),
+    play = (index: number) => {
+      playerStore.setShuffle(false)
+
+      if (index === queueIndex.value)
+        return playerStore.playPause()
+
+      return playerStore.playTrackListIndex(index)
+    },
+
+    clear = async () => {
+      if (!confirmDialog.value)
+        return
+
+      const userConfirmed = await confirmDialog.value.open(
+        'Clear queue',
+        'Do you really want to clear the queue?'
+      )
+
+      if (!userConfirmed)
+        return
+
+      playerStore.clearQueue()
+    }
+
+  return vine`
+    <div class="row">
+      <span class="main-title flex-grow">
+        <Icon icon="queue" />
+        Player Queue
+      </span>
+      <button :disabled="!tracks.length" @click="play">
+        <Icon icon="play" /> Play
+      </button>
+      <button :disabled="!tracks.length" @click="shuffle">
+        <Icon icon="shuffle" /> Shuffle
+      </button>
+      <button :disabled="!tracks.length" @click="clear">
+        <Icon icon="trash" /> Clear
+      </button>
+    </div>
+
+    <TrackList
+      v-if="tracks.length"
+      :tracks="tracks"
+      active-by="index"
+      :show-image="true"
+    >
+      <template #actions="{ index }">
+        <DropdownItem
+          icon="trash"
+          :disabled="index === queueIndex"
+          @click="remove(index)"
+        >
+          Remove
+        </DropdownItem>
+      </template>
+    </TrackList>
+    <EmptyIndicator v-else />
+
+    <Teleport to="#dialogBoxes">
+      <ConfirmDialog ref="confirmDialog" />
+    </Teleport>
+  `;
+}+
\ No newline at end of file
diff --git a/src/view/Playlist.vine.ts b/src/view/Playlist.vine.ts
@@ -0,0 +1,194 @@
+import { ref, nextTick, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+
+import type { Playlist, Track } from '../types'
+
+import { useSubsonicApi } from '../subsonicApi'
+import { useMainStore } from '../store/main'
+import { usePlayerStore } from '../store/player'
+import { usePlaylistStore } from '../store/playlist'
+
+import { formatDuration, formatBytes } from '../utils'
+
+import { EditPlaylistModal } from '../components/EditPlaylistModal.vine'
+import { TrackList } from '../components/TrackList.vine'
+import { 
+  type ConfirmDialogExpose,
+  ConfirmDialog
+} from '../components/ConfirmDialog.vine'
+
+export const PlaylistView = ({ id }: { id: string }) => {
+  const
+    router = useRouter(),
+    route = useRoute(),
+    subsonicApi = useSubsonicApi(),
+
+    mainStore = useMainStore(),
+    playlistStore = usePlaylistStore(),
+    playerStore = usePlayerStore(),
+
+    playlist = ref<Playlist | null>(null),
+    showEditModal = ref<boolean>(false),
+    confirmDialog = ref<ConfirmDialogExpose | null>(null),
+
+    fetchPlaylist = async () => {
+      try {
+        mainStore.isLoading = true
+        playlist.value = await subsonicApi.getPlaylist(id)
+      } catch (err) {
+        console.error(err)
+      } finally {
+        mainStore.isLoading = false
+      }
+    },
+
+    playNow = () => {
+      if (!playlist.value?.tracks?.length)
+        return
+
+      const currentTrack = playerStore.track
+
+      if (
+        currentTrack && (
+          playlist.value.tracks.some(
+            (t: Track) => t.id === currentTrack.id
+          )
+        )
+      ) return playerStore.playPause()
+
+      return playerStore.playNow(playlist.value.tracks)
+    },
+
+    shuffleNow = () => {
+      if (!playlist.value?.tracks)
+        return
+
+      playerStore.shuffleNow(playlist.value.tracks)
+      nextTick(() => router.push({ name: 'queue' }))
+    },
+
+    removeTrack = (index: number) => {
+      if (!playlist.value?.tracks)
+        return
+
+      playlist.value.tracks.splice(index, 1)
+      return playlistStore.removeTrack(id, index)
+    },
+
+    deletePlaylist = async() => {
+      if (!playlist.value || !confirmDialog.value)
+        return
+
+      const userConfirmed = await confirmDialog.value.open(
+        'Delete Playlist',
+        `Do you really want to delete the playlist "${playlist.value.name}"?`
+      )
+
+      if (!userConfirmed) return
+      await playlistStore.delete(id)
+      router.replace({ name: 'playlists' })
+    },
+
+    openEditModal = () => {
+      showEditModal.value = true
+    },
+
+    applyPlaylistUpdate = (updated: Playlist) => {
+      playlist.value = { ...playlist.value, ...updated }
+      playlistStore.update(playlist.value)
+    },
+
+    calcPlaylistSize = (): number => (
+      (!playlist.value?.tracks)
+      ? 0
+      : (playlist.value.tracks.reduce(
+        (prev, cur) => prev + cur.size,
+        0
+      ))
+    )
+
+  watch(
+    () => id,
+    fetchPlaylist,
+    { immediate: true }
+  )
+
+  return vine`
+    <template v-if="playlist">
+      <Header :image="playlist.image" :hover="'Play/Pause'" @click="playNow">
+        <small class="noselect">Playlist</small>
+        <h1>{{ playlist.name }}</h1>
+
+        <div v-if="playlist.owner" class="row align-items-center">
+          <span class="bold">
+            by {{ playlist.owner }}
+          </span>
+          <span class="badge" v-if="playlist.isPublic">
+            <Icon icon="globe" class="xsmall" />
+            Public
+          </span>
+        </div>
+
+        <div class="scroll" v-if="playlist.comment">
+          {{ playlist.comment }}
+        </div>
+
+        <div class="row flex-wrap align-items-center">
+          <span><strong>{{ playlist.trackCount }}</strong> tracks</span>
+          <span class="seperator" />
+          <strong>{{ formatDuration(playlist.duration) }}</strong>
+          <span class="seperator" />
+          <strong>{{ formatBytes(calcPlaylistSize()) }}</strong>
+        </div>
+
+        <div class="row align-items-center">
+          <button class="contrast" :disabled="playlist.tracks.length === 0" @click="playNow()">
+            <Icon icon="play" /> Play
+          </button>
+          <button :disabled="playlist.tracks.length === 0" data-tooltip="Shuffle" @click="shuffleNow()">
+            <Icon icon="shuffle" />
+          </button>
+          <OverflowMenu @click.stop>
+            <DropdownItem :disabled="playlist.isReadOnly" icon="edit" @click="openEditModal">
+              Edit
+            </DropdownItem>
+            <DropdownItem divider />
+            <DropdownItem :disabled="playlist.isReadOnly" icon="trash" @click="deletePlaylist">
+              Delete
+            </DropdownItem>
+          </OverflowMenu>
+        </div>
+      </Header>
+
+      <TrackList
+        v-if="playlist.tracks.length > 0"
+        :tracks="playlist.tracks"
+        :is-playlist-view="true"
+        @remove-track="removeTrack"
+      >
+        <template #context-menu="{ index }">
+          <DropdownItem divider />
+          <DropdownItem
+            icon="trash"
+            variant="danger"
+            @click="removeTrack(index)"
+          >
+            Remove
+          </DropdownItem>
+        </template>
+      </TrackList>
+
+      <EmptyIndicator v-else-if="!isLoading" />
+    </template>
+
+    <Teleport to="#dialogBoxes">
+      <ConfirmDialog ref="confirmDialog" />
+      <EditPlaylistModal
+        v-if="showEditModal"
+        :playlist="playlist"
+        @update-playlist="applyPlaylistUpdate"
+        @close="showEditModal = false"
+      />
+    </Teleport>
+  `
+}
diff --git a/src/view/Playlists.vine.ts b/src/view/Playlists.vine.ts
@@ -0,0 +1,107 @@
+import { ref, watch } from 'vue'
+import { orderBy } from 'lodash-es'
+
+import type { Playlist } from '../types'
+
+import { useMainStore } from '../store/main'
+import { usePlaylistStore } from '../store/playlist'
+
+import { EditPlaylistModal } from '../components/EditPlaylistModal.vine'
+import { PlaylistList } from '../components/PlaylistList.vine'
+import { 
+  type ConfirmDialogExpose,
+  ConfirmDialog
+} from '../components/ConfirmDialog.vine'
+
+export const PlaylistsView = () => {
+  const
+    mainStore = useMainStore(),
+    playlistStore = usePlaylistStore(),
+
+    showAddModal = ref(false),
+    editingPlaylist = ref<Playlist | null>(null),
+    confirmDialog = ref<ConfirmDialogExpose | null>(null),
+
+    playlists = ref<Playlist[]>([]),
+
+    closeModal = () => {
+      editingPlaylist.value = null
+      showAddModal.value = false
+    },
+    startCreate = () => {
+      editingPlaylist.value = null
+      showAddModal.value = true
+    },
+    openEditPlaylist = (playlist: Playlist) => {
+      editingPlaylist.value = playlist
+      showAddModal.value = true
+    },
+
+    createPlaylist = async (name: string) => {
+      await playlistStore.create(name)
+      closeModal()
+    },
+    updatePlaylist = async (playlist: Playlist) => {
+      await playlistStore.update(playlist)
+      closeModal()
+    },
+    deletePlaylist = async (id: string) => {
+      if (!confirmDialog.value)
+        return
+
+      const userConfirmed = await confirmDialog.value.open(
+        'Remove Playlist',
+        'Do you really want to remove the playlist?'
+      )
+
+      if (!userConfirmed)
+        return
+
+      await playlistStore.delete(id)
+    }
+
+  watch(
+    playlistStore,
+    async () => {
+      mainStore.isLoading = true
+      playlists.value = playlistStore.playlists
+      mainStore.isLoading = false
+    },
+    { deep: true, immediate: true }
+  )
+
+  return vine`
+    <div class="row align-items-center justify-content-space-between">
+      <span class="main-title">
+        <Icon icon="playlist" />
+        Playlists
+      </span>
+      <button variant="link" class="mx-2" @click="startCreate">
+        <Icon icon="plus" />
+        Create Playlist
+      </button>
+    </div>
+
+    <PlaylistList
+      v-if="playlists.length"
+      :items="playlists"
+      :is-playlist-view="true"
+      @edit-playlist="openEditPlaylist"
+      @remove-playlist="deletePlaylist"
+    />
+
+    <EmptyIndicator  v-if="!mainStore.isLoading && playlists.length === 0" />
+
+    <Teleport to="#dialogBoxes">
+      <ConfirmDialog ref="confirmDialog" />
+      <EditPlaylistModal
+        v-if="showAddModal"
+        :playlist="editingPlaylist"
+        :mode="editingPlaylist ? 'edit' : 'create'"
+        @create-playlist="createPlaylist"
+        @update-playlist="updatePlaylist"
+        @close="closeModal"
+      />
+    </Teleport>
+  `
+}
diff --git a/src/view/Root.vine.ts b/src/view/Root.vine.ts
@@ -0,0 +1,50 @@
+import { computed } from 'vue'
+import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
+
+import { useMainStore } from '../store/main'
+
+import { SearchInput } from '../components/SearchInput.vine'
+import { NavSidebar, NavBar } from '../components/Navbars.vine'
+import { Player } from '../components/Player.vine'
+
+export const RootView = () => {
+  const 
+    mainStore = useMainStore(),
+    route = useRoute(),
+
+    isAuthenticated = computed(() => !!mainStore.serverInfo),
+
+    routeOnAttributes = (r: RouteLocationNormalizedLoaded) => (
+      (['album', 'genre', 'artist', ' search', 'queue'].includes(r.name as string))
+      ? { key: JSON.stringify(r.params) }
+      : {}
+    )
+
+  return vine`
+    <router-view v-slot="{ Component, route: viewRoute }">
+      <NavSidebar v-if="isAuthenticated" />
+      <main>
+        <SearchInput v-if="isAuthenticated" />
+        <component
+          :is="Component"
+          v-if="!viewRoute.meta.keepAlive"
+          v-bind="routeOnAttributes(viewRoute)"
+        />
+        <keep-alive v-else max="5">
+          <component
+            :is="Component"
+            v-bind="routeOnAttributes(viewRoute)"
+          />
+        </keep-alive>
+      </main>
+      <Player v-if="isAuthenticated" />
+      <NavBar v-if="isAuthenticated" />
+    </router-view>
+
+    <transition name="fade">
+      <div v-if="mainStore.loaderVisible" class="loader-overlay">
+        <img src="" alt="Loading...">
+      </div>
+    </transition>
+  `
+}
diff --git a/src/view/SearchResult.vine.ts b/src/view/SearchResult.vine.ts
@@ -0,0 +1,99 @@
+import { ref, watch } from 'vue'
+
+import type { SearchMode, Artist, Album, Track } from '../types'
+import { useSubsonicApi } from '../subsonicApi'
+import { useMainStore } from '../store/main'
+
+import { AlbumList } from '../components/AlbumList.vine'
+import { ArtistList } from '../components/ArtistList.vine'
+import { TrackList } from '../components/TrackList.vine'
+
+interface State {
+  artists: Artist[],
+  albums: Album[],
+  tracks: Track[],
+  hasMore: boolean,
+}
+
+export const SearchResultView = ({ query, mode = null }: { query: string, mode?: SearchMode }) => {
+  const
+    subsonicApi = useSubsonicApi(),
+    mainStore = useMainStore(),
+    pageSize = 20,
+    state = ref<State>({
+      artists: [],
+      albums: [],
+      tracks: [],
+      hasMore: true,
+    } as State),
+
+    loadMore = async() => {
+      try {
+        mainStore.isLoading = true
+
+        subsonicApi.search(
+          query,
+          mode,
+          pageSize,
+          (state.value.albums.length + state.value.artists.length + state.value.tracks.length)
+        ).then(
+          result => {
+            const numResults = result.albums.length + result.artists.length + result.tracks.length;
+
+            state.value.artists.push(...result.artists)
+            state.value.albums.push(...result.albums)
+            state.value.tracks.push(...result.tracks)
+            state.value.hasMore = (
+              (numResults >= pageSize)
+              ? true
+              : false
+            )
+          }
+        )
+      } finally {
+        mainStore.isLoading = false
+      }
+    }
+
+  watch(
+    () => [ query, mode ],
+    () => {
+      state.value = {
+        artists: [],
+        albums: [],
+        tracks: [],
+        hasMore: true,
+      } as State;
+      loadMore()
+    }
+  )
+
+  return vine`
+    <template v-if="!!state.artists.length">
+      <router-link v-if="!mode" :to="{ params: { mode: 'artist' }, query: $route.query }" class="section-title">
+        <Icon icon="artists" class="title-color" />
+        Artists <small>({{ state.artists.length }})</small>
+      </router-link>
+      <ArtistList :items="state.artists" />
+    </template>
+
+    <template v-if="!!state.albums.length">
+      <router-link v-if="!mode" :to="{ params: { mode: 'album' }, query: $route.query }" class="section-title">
+        <Icon icon="albums" />
+        Albums <small>({{ state.albums.length }})</small>
+      </router-link>
+      <AlbumList :items="state.albums" />
+    </template>
+
+    <template v-if="!!state.tracks.length">
+      <router-link :to="{ params: { mode: 'track' }, query: $route.query }" class="section-title">
+        <Icon icon="tracks" class="title-color" />
+        Tracks <small>({{ state.tracks.length }}{{ state.hasMore ? "+" : "" }})</small>
+      </router-link>
+      <TrackList :tracks="state.tracks" />
+    </template>
+
+    <EmptyIndicator v-if="!mainStore.isLoading && !state.hasMore && !state.artists.length && !state.albums.length && !state.tracks.length" label="No results" />
+    <InfiniteLoader :is-loading="mainStore.isLoading" :has-more="state.hasMore" @load-more="loadMore" />
+  `;
+}+
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
@@ -0,0 +1,43 @@
+{
+  "extends": [
+    "@vue/tsconfig/tsconfig.dom.json",
+    "@vue/tsconfig/tsconfig.lib.json"
+  ],
+  "compilerOptions": {
+    "target": "esnext",
+    "module": "esnext",
+    "strict": true,
+    "jsx": "preserve",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "forceConsistentCasingInFileNames": true,
+    "useDefineForClassFields": true,
+    "sourceMap": true,
+    "noImplicitAny": false,
+    "types": [
+      "vite/client",
+      "vue-vine/macros"
+    ],
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+    "lib": [
+      "esnext",
+      "dom",
+      "dom.iterable",
+      "scripthost"
+    ]
+  },
+  "vueCompilerOptions": {
+    "skipTemplateCodegen": true
+  },
+  "include": [
+    "src/**/*.ts",
+    "src/**/*.d.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}
diff --git a/vite.config.mjs b/vite.config.mjs
@@ -0,0 +1,26 @@
+import process from 'node:process'
+
+import { defineConfig, loadEnv } from 'vite'
+import { VineVitePlugin } from 'vue-vine/vite'
+import checker from 'vite-plugin-checker'
+
+export default defineConfig((mode) => {
+  const
+    env = loadEnv(mode, process.cwd(), '')
+
+  return {
+    base: env.BASE_URL ?? '/',
+    build: {
+      outDir: 'dist',
+      assetsDir: '.', 
+      target: 'esnext',
+      minify: true,
+    },
+    plugins: [
+      VineVitePlugin(),
+      checker({
+        vueTsc: true,
+      }),
+    ],
+  }
+});
diff --git a/yarn.lock b/yarn.lock
@@ -0,0 +1,1517 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.27.1":
+  version "7.29.0"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
+  integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.28.5"
+    js-tokens "^4.0.0"
+    picocolors "^1.1.1"
+
+"@babel/helper-string-parser@^7.27.1":
+  version "7.27.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
+  integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+
+"@babel/helper-validator-identifier@^7.28.5":
+  version "7.28.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
+  integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+
+"@babel/parser@^7.28.0", "@babel/parser@^7.29.2", "@babel/parser@^7.29.3":
+  version "7.29.3"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e"
+  integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==
+  dependencies:
+    "@babel/types" "^7.29.0"
+
+"@babel/types@^7.28.0", "@babel/types@^7.29.0":
+  version "7.29.0"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
+  integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
+  dependencies:
+    "@babel/helper-string-parser" "^7.27.1"
+    "@babel/helper-validator-identifier" "^7.28.5"
+
+"@emnapi/core@1.10.0", "@emnapi/core@^1.5.0":
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
+  integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
+  dependencies:
+    "@emnapi/wasi-threads" "1.2.1"
+    tslib "^2.4.0"
+
+"@emnapi/runtime@1.10.0", "@emnapi/runtime@^1.5.0":
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
+  integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
+  dependencies:
+    tslib "^2.4.0"
+
+"@emnapi/wasi-threads@1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
+  integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
+  dependencies:
+    tslib "^2.4.0"
+
+"@jridgewell/sourcemap-codec@^1.5.5":
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+  integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@module-federation/error-codes@0.22.0":
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/@module-federation/error-codes/-/error-codes-0.22.0.tgz#31ccc990dc240d73912ba7bd001f7e35ac751992"
+  integrity sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==
+
+"@module-federation/runtime-core@0.22.0":
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz#7321ec792bb7d1d22bee6162ec43564b769d2a3c"
+  integrity sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==
+  dependencies:
+    "@module-federation/error-codes" "0.22.0"
+    "@module-federation/sdk" "0.22.0"
+
+"@module-federation/runtime-tools@0.22.0":
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz#36f2a7cb267af208a9d1a237fe9a71b4bf31431e"
+  integrity sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==
+  dependencies:
+    "@module-federation/runtime" "0.22.0"
+    "@module-federation/webpack-bundler-runtime" "0.22.0"
+
+"@module-federation/runtime@0.22.0":
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.22.0.tgz#f789c9ef40d846d110711c8221ecc0ad938d43d8"
+  integrity sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==
+  dependencies:
+    "@module-federation/error-codes" "0.22.0"
+    "@module-federation/runtime-core" "0.22.0"
+    "@module-federation/sdk" "0.22.0"
+
+"@module-federation/sdk@0.22.0":
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.22.0.tgz#6ad4c1de85a900c3c80ff26cb87cce253e3a2770"
+  integrity sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==
+
+"@module-federation/webpack-bundler-runtime@0.22.0":
+  version "0.22.0"
+  resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz#dcbe8f972d722fe278e6a7c21988d4bee53d401d"
+  integrity sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==
+  dependencies:
+    "@module-federation/runtime" "0.22.0"
+    "@module-federation/sdk" "0.22.0"
+
+"@napi-rs/wasm-runtime@1.0.7":
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz#dcfea99a75f06209a235f3d941e3460a51e9b14c"
+  integrity sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==
+  dependencies:
+    "@emnapi/core" "^1.5.0"
+    "@emnapi/runtime" "^1.5.0"
+    "@tybys/wasm-util" "^0.10.1"
+
+"@napi-rs/wasm-runtime@^1.1.4":
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
+  integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
+  dependencies:
+    "@tybys/wasm-util" "^0.10.1"
+
+"@nodelib/fs.scandir@2.1.5":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.5"
+    run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.5"
+    fastq "^1.6.0"
+
+"@oxc-project/types@=0.130.0":
+  version "0.130.0"
+  resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.130.0.tgz#a7825148711dc28805c46cfc21d94b63a4d41e88"
+  integrity sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==
+
+"@parcel/watcher-android-arm64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564"
+  integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==
+
+"@parcel/watcher-darwin-arm64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e"
+  integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==
+
+"@parcel/watcher-darwin-x64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063"
+  integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==
+
+"@parcel/watcher-freebsd-x64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53"
+  integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==
+
+"@parcel/watcher-linux-arm-glibc@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a"
+  integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==
+
+"@parcel/watcher-linux-arm-musl@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152"
+  integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==
+
+"@parcel/watcher-linux-arm64-glibc@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809"
+  integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==
+
+"@parcel/watcher-linux-arm64-musl@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4"
+  integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==
+
+"@parcel/watcher-linux-x64-glibc@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639"
+  integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==
+
+"@parcel/watcher-linux-x64-musl@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2"
+  integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==
+
+"@parcel/watcher-win32-arm64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e"
+  integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==
+
+"@parcel/watcher-win32-ia32@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d"
+  integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==
+
+"@parcel/watcher-win32-x64@2.5.6":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d"
+  integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==
+
+"@parcel/watcher@^2.4.1":
+  version "2.5.6"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1"
+  integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==
+  dependencies:
+    detect-libc "^2.0.3"
+    is-glob "^4.0.3"
+    node-addon-api "^7.0.0"
+    picomatch "^4.0.3"
+  optionalDependencies:
+    "@parcel/watcher-android-arm64" "2.5.6"
+    "@parcel/watcher-darwin-arm64" "2.5.6"
+    "@parcel/watcher-darwin-x64" "2.5.6"
+    "@parcel/watcher-freebsd-x64" "2.5.6"
+    "@parcel/watcher-linux-arm-glibc" "2.5.6"
+    "@parcel/watcher-linux-arm-musl" "2.5.6"
+    "@parcel/watcher-linux-arm64-glibc" "2.5.6"
+    "@parcel/watcher-linux-arm64-musl" "2.5.6"
+    "@parcel/watcher-linux-x64-glibc" "2.5.6"
+    "@parcel/watcher-linux-x64-musl" "2.5.6"
+    "@parcel/watcher-win32-arm64" "2.5.6"
+    "@parcel/watcher-win32-ia32" "2.5.6"
+    "@parcel/watcher-win32-x64" "2.5.6"
+
+"@rolldown/binding-android-arm64@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz#7b250c89f16d74affd581dbe38f702e8c2c644d3"
+  integrity sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==
+
+"@rolldown/binding-darwin-arm64@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz#cd4de96687e6522062984b0503fbffbbc9220023"
+  integrity sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==
+
+"@rolldown/binding-darwin-x64@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz#5b0a631e3784d5a7741dd93097dcf6dfca029960"
+  integrity sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==
+
+"@rolldown/binding-freebsd-x64@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz#d82e561079db89f796438f56ec11bb3565ee1875"
+  integrity sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==
+
+"@rolldown/binding-linux-arm-gnueabihf@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz#f2645afff4253c7b46b80ba14af5fd3fc18d45dc"
+  integrity sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==
+
+"@rolldown/binding-linux-arm64-gnu@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz#a16b97f175e7b115c5ece77c7b648d0c868f4486"
+  integrity sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==
+
+"@rolldown/binding-linux-arm64-musl@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz#e695aec4ef2c8713c9d959b42a208059891276da"
+  integrity sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==
+
+"@rolldown/binding-linux-ppc64-gnu@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz#4a9edf16112cbe99cdd396c60efac39cbd1758ac"
+  integrity sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==
+
+"@rolldown/binding-linux-s390x-gnu@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz#314aa3ec1ce8251501d865f98fb91e42a1e671e4"
+  integrity sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==
+
+"@rolldown/binding-linux-x64-gnu@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz#7c51f13cf1141c503ee162830b4fc692d91640be"
+  integrity sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==
+
+"@rolldown/binding-linux-x64-musl@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz#b7213936bbc9310b02a34f71cefd25f9e71f329b"
+  integrity sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==
+
+"@rolldown/binding-openharmony-arm64@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz#006e88acde4f12b41a4c72292685c9dc9e6a3627"
+  integrity sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==
+
+"@rolldown/binding-wasm32-wasi@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz#033525c84da217418232f35be19f1ddc0af4f31e"
+  integrity sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==
+  dependencies:
+    "@emnapi/core" "1.10.0"
+    "@emnapi/runtime" "1.10.0"
+    "@napi-rs/wasm-runtime" "^1.1.4"
+
+"@rolldown/binding-win32-arm64-msvc@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz#febbf109cf1b5837e21369f0e0d2fefca1519c39"
+  integrity sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==
+
+"@rolldown/binding-win32-x64-msvc@1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz#dfb32a67ccb0deaa3c9a57f6cb4890b5697dfa2c"
+  integrity sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==
+
+"@rolldown/pluginutils@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be"
+  integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
+
+"@rsbuild/core@^1.5.17":
+  version "1.7.5"
+  resolved "https://registry.yarnpkg.com/@rsbuild/core/-/core-1.7.5.tgz#971855fb6ca760c61eff5f67a09b39fae1dfcbd0"
+  integrity sha512-i37urpoV4y9NSsGiUOuLdoI42KJ5h4gAZ8EG8Ilmsond3bxoAoOCu7YvC+1pJ7p+r16suVPW8cki891ZKHOoXQ==
+  dependencies:
+    "@rspack/core" "~1.7.10"
+    "@rspack/lite-tapable" "~1.1.0"
+    "@swc/helpers" "^0.5.20"
+    core-js "~3.47.0"
+    jiti "^2.6.1"
+
+"@rspack/binding-darwin-arm64@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz#ea43ac25a9ff99a9faf6c820f5d174a32974e95c"
+  integrity sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==
+
+"@rspack/binding-darwin-x64@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz#5c724d91559d642d4a5e6aa4ed380c30bd0f64c0"
+  integrity sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==
+
+"@rspack/binding-linux-arm64-gnu@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz#429119939bbe9d51a72caf99cffb8febe0f870fe"
+  integrity sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==
+
+"@rspack/binding-linux-arm64-musl@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz#d939b8c2c5bf35380d3c860402f7063031ef469a"
+  integrity sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==
+
+"@rspack/binding-linux-x64-gnu@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz#03567317a7e8cfc62d994dcf9683f932fd22054a"
+  integrity sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==
+
+"@rspack/binding-linux-x64-musl@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz#d93c93ea796eae1572b2353a50d58cc6218c53b6"
+  integrity sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==
+
+"@rspack/binding-wasm32-wasi@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz#c90235032fb14de50baf535592069923c1308f4e"
+  integrity sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==
+  dependencies:
+    "@napi-rs/wasm-runtime" "1.0.7"
+
+"@rspack/binding-win32-arm64-msvc@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz#0afcfde6a77cdf6fa6a85de4f8a39b94a593aab2"
+  integrity sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==
+
+"@rspack/binding-win32-ia32-msvc@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz#46606834538e84cd0f95f19089695ab122d69586"
+  integrity sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==
+
+"@rspack/binding-win32-x64-msvc@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz#e486a33fc1227ec9cbd70439ef1b32ead1faec68"
+  integrity sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==
+
+"@rspack/binding@1.7.11":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/binding/-/binding-1.7.11.tgz#30f3e87242d9dcb3744edc22752cf24a9ceb4d61"
+  integrity sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==
+  optionalDependencies:
+    "@rspack/binding-darwin-arm64" "1.7.11"
+    "@rspack/binding-darwin-x64" "1.7.11"
+    "@rspack/binding-linux-arm64-gnu" "1.7.11"
+    "@rspack/binding-linux-arm64-musl" "1.7.11"
+    "@rspack/binding-linux-x64-gnu" "1.7.11"
+    "@rspack/binding-linux-x64-musl" "1.7.11"
+    "@rspack/binding-wasm32-wasi" "1.7.11"
+    "@rspack/binding-win32-arm64-msvc" "1.7.11"
+    "@rspack/binding-win32-ia32-msvc" "1.7.11"
+    "@rspack/binding-win32-x64-msvc" "1.7.11"
+
+"@rspack/core@^1.5.8", "@rspack/core@~1.7.10":
+  version "1.7.11"
+  resolved "https://registry.yarnpkg.com/@rspack/core/-/core-1.7.11.tgz#8d7d77db3b71332afd22a9c90904fe18a6832e2c"
+  integrity sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==
+  dependencies:
+    "@module-federation/runtime-tools" "0.22.0"
+    "@rspack/binding" "1.7.11"
+    "@rspack/lite-tapable" "1.1.0"
+
+"@rspack/lite-tapable@1.1.0", "@rspack/lite-tapable@~1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz#3cfdafeed01078e116bd4f191b684c8b484de425"
+  integrity sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==
+
+"@swc/helpers@^0.5.20":
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.21.tgz#0b1b020317ee1282860ca66f7e9a7c7790f05ae0"
+  integrity sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==
+  dependencies:
+    tslib "^2.8.0"
+
+"@ts-morph/common@~0.27.0":
+  version "0.27.0"
+  resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.27.0.tgz#e83a1bd7cbac054045c6246a7c4c99eab7692d46"
+  integrity sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==
+  dependencies:
+    fast-glob "^3.3.3"
+    minimatch "^10.0.1"
+    path-browserify "^1.0.1"
+
+"@tybys/wasm-util@^0.10.1":
+  version "0.10.2"
+  resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e"
+  integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==
+  dependencies:
+    tslib "^2.4.0"
+
+"@types/web-bluetooth@^0.0.20":
+  version "0.0.20"
+  resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
+  integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
+
+"@typescript/native-preview-darwin-arm64@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260515.1.tgz#180f98fba2e7aae70b48977f52ddac9db2da745e"
+  integrity sha512-CSvyLkNV0k4Ro0B6nzNdDXFrRD8aa6sDvXSGla2FTmBio+JLKqNuJXq1ppyj42j9pAwdUhDZV/PNdjeHRCBjWQ==
+
+"@typescript/native-preview-darwin-x64@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260515.1.tgz#aa4f9af9da01b852897c312699919b0ad4a5a875"
+  integrity sha512-QTmm0dpF6CC3hrfudAyVcIvbr1tlBTZ7aGQHIs4PYmi+tnTi2R8jy6gDBCJWlDBSXqjPWlQZrdiOxRBCSlShSA==
+
+"@typescript/native-preview-linux-arm64@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260515.1.tgz#c21d58c3cf53c6d4751e7edc466d393e5e2d8543"
+  integrity sha512-bH/CYrtXURXr3YwqJmlKblQ858wuJ0Qw2VSMvlsWwYZpb8eOd07T2bl1Ba6QxGBuL6EotcDQFd2OYJIj0uvM7A==
+
+"@typescript/native-preview-linux-arm@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260515.1.tgz#c5eef8233259355e74bd3afd906498839a4025a6"
+  integrity sha512-0b+Sxh8JUNMqvw06wfIoh45+G68/ikCW+aRqHGipU2uc9M9dWUKvI3zDyzP1rF6FOJ7xMKMbTbi5b2IvcRqh8g==
+
+"@typescript/native-preview-linux-x64@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260515.1.tgz#472e002e889274c551fd165b82a9180f011e5763"
+  integrity sha512-eXgYE6pmkCiEEX7T43TYeF24uiY8+h9mbi9YSvnCoRp4BLgn9b7uJagh4ctSGCNxUy3QSDeoznkWfSVlrvJ5pA==
+
+"@typescript/native-preview-win32-arm64@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260515.1.tgz#a87127a6f097b1ca548cd3bc5ac0ce7342fdf4ea"
+  integrity sha512-K1YJimcOr3/lFTJyOChT1GnRdfvoHtIvG/qcwHhHpeCBzpGfG7u2aH9MkBgsXqNJHAXHLnQRuON2uYgU8wWDzw==
+
+"@typescript/native-preview-win32-x64@7.0.0-dev.20260515.1":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260515.1.tgz#24b8fb063b457f64f795097a3a896310b0b28a13"
+  integrity sha512-1cGEWLao/d2+Q3yMvtnDES85sM9HuI6J6PBmrcjwXzsQxnjXJkujStdp+LB6JZfY+VSDi+cpaCZmrKU6sYUfkg==
+
+"@typescript/native-preview@latest":
+  version "7.0.0-dev.20260515.1"
+  resolved "https://registry.yarnpkg.com/@typescript/native-preview/-/native-preview-7.0.0-dev.20260515.1.tgz#4d55cb805290192139aeb8c6bfbb1e6b6100aef2"
+  integrity sha512-dWhzJGb9iJfCDsjz/LzPkbMj5XDbFWyZmDyTixcmQPgX5zFOPZ+sAdlCpTZFGEJhA7OqXG+lZyajbJfjYvQorA==
+  optionalDependencies:
+    "@typescript/native-preview-darwin-arm64" "7.0.0-dev.20260515.1"
+    "@typescript/native-preview-darwin-x64" "7.0.0-dev.20260515.1"
+    "@typescript/native-preview-linux-arm" "7.0.0-dev.20260515.1"
+    "@typescript/native-preview-linux-arm64" "7.0.0-dev.20260515.1"
+    "@typescript/native-preview-linux-x64" "7.0.0-dev.20260515.1"
+    "@typescript/native-preview-win32-arm64" "7.0.0-dev.20260515.1"
+    "@typescript/native-preview-win32-x64" "7.0.0-dev.20260515.1"
+
+"@umami/node@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@umami/node/-/node-0.4.0.tgz#bbed7c5fed3ffb1863afbbdac29a03bdf024951f"
+  integrity sha512-pyphprbiF7KiDSc+SWZ4/rVM8B5vU27zIiFfEPj2lEqczpI4xAKSp+dM3tlzyRAWJL32fcbCfAaLGhJZQV13Rg==
+
+"@volar/language-core@2.4.20":
+  version "2.4.20"
+  resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.20.tgz#be6d4efc6bb2f77d6c01bbbb3ef53661a869e0d0"
+  integrity sha512-dRDF1G33xaAIDqR6+mXUIjXYdu9vzSxlMGfMEwBxQsfY/JMUEXSpLTR057oTKlUQ2nIvCmP9k94A8h8z2VrNSA==
+  dependencies:
+    "@volar/source-map" "2.4.20"
+
+"@volar/language-core@2.4.28":
+  version "2.4.28"
+  resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.28.tgz#c21f365a91c1dffe8bd7264fd491770c8d74fef3"
+  integrity sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==
+  dependencies:
+    "@volar/source-map" "2.4.28"
+
+"@volar/language-server@2.4.20":
+  version "2.4.20"
+  resolved "https://registry.yarnpkg.com/@volar/language-server/-/language-server-2.4.20.tgz#f0b52d1821257b9530ca312bf1495143df5b29a6"
+  integrity sha512-fNNFzEad0sO4pVZnpHggglbIeaKjLs4vH1JPPN+zd/4hSEI2u8+Qck10JhswCSO6xFTFbKxVquvWu2U2tT0EHQ==
+  dependencies:
+    "@volar/language-core" "2.4.20"
+    "@volar/language-service" "2.4.20"
+    "@volar/typescript" "2.4.20"
+    path-browserify "^1.0.1"
+    request-light "^0.7.0"
+    vscode-languageserver "^9.0.1"
+    vscode-languageserver-protocol "^3.17.5"
+    vscode-languageserver-textdocument "^1.0.11"
+    vscode-uri "^3.0.8"
+
+"@volar/language-service@2.4.20":
+  version "2.4.20"
+  resolved "https://registry.yarnpkg.com/@volar/language-service/-/language-service-2.4.20.tgz#0563cf0b5489ce9bcef7710d09b44e23bd421cd4"
+  integrity sha512-LoCD4rEI1Bj5ld6b+2GH1SbDGnoisvJ5skHlrkFEtJWw0T2+bhqGUXwekFudV/bRtp8fPhvD5ZUtjWSW0VRztg==
+  dependencies:
+    "@volar/language-core" "2.4.20"
+    vscode-languageserver-protocol "^3.17.5"
+    vscode-languageserver-textdocument "^1.0.11"
+    vscode-uri "^3.0.8"
+
+"@volar/source-map@2.4.20":
+  version "2.4.20"
+  resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.20.tgz#55ff844410d8d670ef2c3722e2717223edbf8717"
+  integrity sha512-mVjmFQH8mC+nUaVwmbxoYUy8cww+abaO8dWzqPUjilsavjxH0jCJ3Mp8HFuHsdewZs2c+SP+EO7hCd8Z92whJg==
+
+"@volar/source-map@2.4.28":
+  version "2.4.28"
+  resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.28.tgz#b40254e8c96199e5f1e0796777c593c617ad270e"
+  integrity sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==
+
+"@volar/typescript@2.4.20":
+  version "2.4.20"
+  resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.20.tgz#c388d6fe5ee31ddeb5338d01dbbfc71054065a7c"
+  integrity sha512-Oc4DczPwQyXcVbd+5RsNEqX6ia0+w3p+klwdZQ6ZKhFjWoBP9PCPQYlKYRi/tDemWphW93P/Vv13vcE9I9D2GQ==
+  dependencies:
+    "@volar/language-core" "2.4.20"
+    path-browserify "^1.0.1"
+    vscode-uri "^3.0.8"
+
+"@volar/typescript@2.4.28":
+  version "2.4.28"
+  resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.28.tgz#83f86356e84eb101b8081a44c104f2f2ced8411f"
+  integrity sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==
+  dependencies:
+    "@volar/language-core" "2.4.28"
+    path-browserify "^1.0.1"
+    vscode-uri "^3.0.8"
+
+"@vue-vine/compiler@1.7.27":
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/@vue-vine/compiler/-/compiler-1.7.27.tgz#aaad91d4e20cce9a40e64e346e56a627e356bb26"
+  integrity sha512-aWLc1TaCq1m379hMGAUdgZRe6OR7fTqQllSb3BR9oz3SA3hiIZbcvcH89lFr0Vj48CPj2RC16p8bAVCdZDrXAg==
+  dependencies:
+    "@babel/parser" "^7.28.0"
+    "@babel/types" "^7.28.0"
+    "@vue/compiler-dom" "^3.5.22"
+    "@vue/compiler-ssr" "^3.5.22"
+    "@vue/shared" "^3.5.22"
+    get-tsconfig "^4.10.1"
+    hash-sum "^2.0.0"
+    line-column "^1.0.2"
+    lru-cache "^11.1.0"
+    magic-string "^0.30.17"
+    merge-source-map "^1.1.0"
+    postcss "^8.5.6"
+    postcss-selector-parser "^7.1.0"
+    ts-morph "^26.0.0"
+
+"@vue-vine/language-service@1.7.27":
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/@vue-vine/language-service/-/language-service-1.7.27.tgz#54f4187503cd1926af7bbb9ac6587815dd03ba0f"
+  integrity sha512-x/ZFv9WyMeh4fBTOd8xsRziQDd48wkDdI+fRr21kEueRahksoGnuoY5KyygoHZOQP237cls6oqZ48fuHe36scA==
+  dependencies:
+    "@umami/node" "^0.4.0"
+    "@volar/language-core" "2.4.20"
+    "@volar/language-server" "2.4.20"
+    "@volar/typescript" "2.4.20"
+    "@vue-vine/compiler" "1.7.27"
+    "@vue/language-core" "3.0.3"
+    "@vue/shared" "^3.5.22"
+    muggle-string "^0.4.1"
+
+"@vue-vine/rsbuild-plugin@1.7.27":
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/@vue-vine/rsbuild-plugin/-/rsbuild-plugin-1.7.27.tgz#1ebf116b448049a36ce22a241503974a2a47aa63"
+  integrity sha512-rO401ayAg8YA+uij2Q2u8ckHz1MQ7gtWNfPEQfQhDhasZI2Q9ypZgm6QdAvZIs7OjUPzKerIJsBBTNyU1vPKAw==
+  dependencies:
+    "@rsbuild/core" "^1.5.17"
+    "@vue-vine/compiler" "1.7.27"
+    "@vue-vine/rspack-loader" "1.7.27"
+
+"@vue-vine/rspack-loader@1.7.27":
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/@vue-vine/rspack-loader/-/rspack-loader-1.7.27.tgz#0aec2490714ebeeaa4a1ddc02bfc23e9b6392257"
+  integrity sha512-5Ls0qxtazPtKzy0q1mnw//Xhtp7uIYjXZZEoY20g1p31ni7JjE7lPn/J7zn1rpscmySBzzaLj/8eZfq4bCf9kg==
+  dependencies:
+    "@rspack/core" "^1.5.8"
+    "@vue-vine/compiler" "1.7.27"
+
+"@vue-vine/vite-plugin@1.7.27":
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/@vue-vine/vite-plugin/-/vite-plugin-1.7.27.tgz#c472c57968412f4a02fd21781cc34f5953c8ca4a"
+  integrity sha512-xAKlPNUR2NJ88KiowpjDjBxJ6ro0Z9BQCP5kZMGyhFSRe3fTmr9loeDAemlKSny0LVI8wnvsKGqfIgTbfgi1gQ==
+  dependencies:
+    "@vue-vine/compiler" "1.7.27"
+
+"@vue/compiler-core@3.5.34":
+  version "3.5.34"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz#6d84a46b7fdf1162cf8225aa2be42918a76ab827"
+  integrity sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==
+  dependencies:
+    "@babel/parser" "^7.29.3"
+    "@vue/shared" "3.5.34"
+    entities "^7.0.1"
+    estree-walker "^2.0.2"
+    source-map-js "^1.2.1"
+
+"@vue/compiler-core@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.6.0-beta.9.tgz#f30a45e9dc71dc25d1b3d984605e815795d5951a"
+  integrity sha512-/qlfk4cbU//tgdwWeAbl4kBvH5ZocjJ6CMGigzRKk1So/0jl6kRpFxVbc6oNsYYH6Xj28ByA+msAes1FjExejg==
+  dependencies:
+    "@babel/parser" "^7.29.2"
+    "@vue/shared" "3.6.0-beta.9"
+    entities "^7.0.1"
+    estree-walker "^2.0.2"
+    source-map-js "^1.2.1"
+
+"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.5.0", "@vue/compiler-dom@^3.5.22":
+  version "3.5.34"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz#6b943e8106822868e74d66c615432bbba6a589be"
+  integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
+  dependencies:
+    "@vue/compiler-core" "3.5.34"
+    "@vue/shared" "3.5.34"
+
+"@vue/compiler-dom@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.6.0-beta.9.tgz#1f59ee86a2130e1777f205d88c140bd2d6b8412f"
+  integrity sha512-Bh2h+Co2Vl85jhNjR+yBU1oIujqQ3XIhF7FzODBStvp8yn2WvsXmm5QlvnE7q+smJkfglTbfFcaou8fjR0Y4ag==
+  dependencies:
+    "@vue/compiler-core" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+
+"@vue/compiler-sfc@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.6.0-beta.9.tgz#ced9e39a90a1666c58423f894f8b129b73c8338d"
+  integrity sha512-13MwFqrIN+AFxR5uT88WYaMTxXycpNNI75sIllz5blXbFaBsDfiegQZj10Pj5G3cgH7WFy2Trs1rTlXrmSgG/w==
+  dependencies:
+    "@babel/parser" "^7.29.2"
+    "@vue/compiler-core" "3.6.0-beta.9"
+    "@vue/compiler-dom" "3.6.0-beta.9"
+    "@vue/compiler-ssr" "3.6.0-beta.9"
+    "@vue/compiler-vapor" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+    estree-walker "^2.0.2"
+    magic-string "^0.30.21"
+    postcss "^8.5.8"
+    source-map-js "^1.2.1"
+
+"@vue/compiler-ssr@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.6.0-beta.9.tgz#78516f31306f85929caaa5c6de28fc620a3f727c"
+  integrity sha512-ufzHVMJoMz7QzMFop03a8KpWAWUh4hiof/tJXvxiDClIFUY7P5OrKk644fV9qlWgTP7V+eQvA7kVSEVRUgYjvA==
+  dependencies:
+    "@vue/compiler-dom" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+
+"@vue/compiler-ssr@^3.5.22":
+  version "3.5.34"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz#0561ae3f9b81564929a8544769eee9cc92a76c42"
+  integrity sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==
+  dependencies:
+    "@vue/compiler-dom" "3.5.34"
+    "@vue/shared" "3.5.34"
+
+"@vue/compiler-vapor@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-vapor/-/compiler-vapor-3.6.0-beta.9.tgz#bbbd34ef0d1f0b47759789279bc486e71170d37a"
+  integrity sha512-1WWCcRjkH/scYJ8dsjNp8+NDf4f9SwQgOcd4Hbl5p0srNPflqrQYvAaJh4N2Szxj5FdkXw2t5uiK35TpFM5uKA==
+  dependencies:
+    "@babel/parser" "^7.29.2"
+    "@vue/compiler-dom" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+    estree-walker "^2.0.2"
+    source-map-js "^1.2.1"
+
+"@vue/compiler-vue2@^2.7.16":
+  version "2.7.16"
+  resolved "https://registry.yarnpkg.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz#2ba837cbd3f1b33c2bc865fbe1a3b53fb611e249"
+  integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==
+  dependencies:
+    de-indent "^1.0.2"
+    he "^1.2.0"
+
+"@vue/devtools-api@^6.6.4":
+  version "6.6.4"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
+  integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
+
+"@vue/devtools-api@^7.7.7":
+  version "7.7.9"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz#999dbea50da6b00cf59a1336f11fdc2b43d9e063"
+  integrity sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==
+  dependencies:
+    "@vue/devtools-kit" "^7.7.9"
+
+"@vue/devtools-kit@^7.7.9":
+  version "7.7.9"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz#bc218a815616e8987df7ab3e10fc1fb3b8706c58"
+  integrity sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==
+  dependencies:
+    "@vue/devtools-shared" "^7.7.9"
+    birpc "^2.3.0"
+    hookable "^5.5.3"
+    mitt "^3.0.1"
+    perfect-debounce "^1.0.0"
+    speakingurl "^14.0.1"
+    superjson "^2.2.2"
+
+"@vue/devtools-shared@^7.7.9":
+  version "7.7.9"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz#fa4c096b744927081a7dda5fcf05f34b1ae6ca14"
+  integrity sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==
+  dependencies:
+    rfdc "^1.4.1"
+
+"@vue/language-core@3.0.3":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.0.3.tgz#f5a24407681693dc352901d6241b370251d7eab1"
+  integrity sha512-I9wY0ULMN9tMSua+2C7g+ez1cIziVMUzIHlDYGSl2rtru3Eh4sXj95vZ+4GBuXwwPnEmYfzSApVbXiVbI8V5Gg==
+  dependencies:
+    "@volar/language-core" "2.4.20"
+    "@vue/compiler-dom" "^3.5.0"
+    "@vue/compiler-vue2" "^2.7.16"
+    "@vue/shared" "^3.5.0"
+    alien-signals "^2.0.5"
+    muggle-string "^0.4.1"
+    path-browserify "^1.0.1"
+    picomatch "^4.0.2"
+
+"@vue/language-core@3.2.9":
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.9.tgz#903dde38a614f6263ace162c9edc2cd479e6c69b"
+  integrity sha512-ie0ojt/0fU/GfIogh+zgHbaYRPlt9S+cLOxcWwF7nTSFh897BVgnFKL2byT4kpp1mlqYWZ2psGwSniyE2xsxYw==
+  dependencies:
+    "@volar/language-core" "2.4.28"
+    "@vue/compiler-dom" "^3.5.0"
+    "@vue/shared" "^3.5.0"
+    alien-signals "^3.2.0"
+    muggle-string "^0.4.1"
+    path-browserify "^1.0.1"
+    picomatch "^4.0.4"
+
+"@vue/reactivity@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.6.0-beta.9.tgz#fdbefc72ab669591a7e5d91e254bd82a70796395"
+  integrity sha512-Mo0GjZB/q7LUJplPjBdKqAAk4mawkzvnk2nNf9CEetp4RQJP5/e/DdmgjYBsr/+UVX5ShasCalwdyknuuIepdw==
+  dependencies:
+    "@vue/shared" "3.6.0-beta.9"
+
+"@vue/runtime-core@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.6.0-beta.9.tgz#3aea972958e5819b82cbb2b19fd74257ba62e3f7"
+  integrity sha512-IPbO/cmw0BSHec17fSE8Mbtv0UFBiiiGhvlUGiJWTa/eOlbiO+mZ9/PRRUQ+v/tkKuwFSzKye7KOKj5aFO1dgQ==
+  dependencies:
+    "@vue/reactivity" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+
+"@vue/runtime-dom@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.6.0-beta.9.tgz#4d9278a4435e4a6ee5e9b3506c866919078f6874"
+  integrity sha512-+WV0V1s/uf9iFwZt41lOtJHbIqKyYdEO+nmW0UOnQLjG9ELz6rKuZ92fEim6NPcKCsucv2BoB1mK9T82khYqgg==
+  dependencies:
+    "@vue/reactivity" "3.6.0-beta.9"
+    "@vue/runtime-core" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+    csstype "^3.2.3"
+
+"@vue/runtime-vapor@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/runtime-vapor/-/runtime-vapor-3.6.0-beta.9.tgz#0b911aacbb4b8b530653f6cfdb956f1d7cbc166d"
+  integrity sha512-UlAziGF77byUa84RgkIdCT4bleZbB4UvQvmxdGHFfscGylSABBZHAMR8WJIwBsy1MsZnlpRSSeDAYB20RicrhQ==
+  dependencies:
+    "@vue/reactivity" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+
+"@vue/server-renderer@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.6.0-beta.9.tgz#49a1fb3e96b33871ca8e1b47f5ca0bc8bb532a65"
+  integrity sha512-qyz37g6pmwHvtXbe7CWKm5+VTmCs4mHrC1uDw4OVZlvwDDZb9pWMzW0kOrzhj1iI6sZ4D9k5fYQIdzBqB7aOqw==
+  dependencies:
+    "@vue/compiler-ssr" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"
+
+"@vue/shared@3.5.34", "@vue/shared@^3.5.0", "@vue/shared@^3.5.22":
+  version "3.5.34"
+  resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.34.tgz#665f2b2fd600f6c180668423909a6fde64cbfccd"
+  integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
+
+"@vue/shared@3.6.0-beta.9":
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.6.0-beta.9.tgz#188ea1fd3f794f6a8948e18fb7761e328e3a6fa5"
+  integrity sha512-nY6DXeelJsmveeNk5YZYGd8q0ulWXACL8Qs/qvkPC4HQkyhPgf+Ja00AkG2hYsEI8Uixia2UpQl4CbObzw0hag==
+
+"@vue/tsconfig@^0.9.1":
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz#6aa901e9f89242b26e1e564c98747278df6882e5"
+  integrity sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==
+
+"@vueuse/core@^11.1.0":
+  version "11.3.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-11.3.0.tgz#bb0bd1f0edd5435d20694dbe51091cf548653a4d"
+  integrity sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==
+  dependencies:
+    "@types/web-bluetooth" "^0.0.20"
+    "@vueuse/metadata" "11.3.0"
+    "@vueuse/shared" "11.3.0"
+    vue-demi ">=0.14.10"
+
+"@vueuse/metadata@11.3.0":
+  version "11.3.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-11.3.0.tgz#be7ac12e3016c0353a3667b372a73aeeee59194e"
+  integrity sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==
+
+"@vueuse/shared@11.3.0":
+  version "11.3.0"
+  resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-11.3.0.tgz#086a4f35bf5bcec5655a03b80eae582605a4b21d"
+  integrity sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==
+  dependencies:
+    vue-demi ">=0.14.10"
+
+alien-signals@^2.0.5:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-2.0.8.tgz#f1592509482c6cf0a5710f09bfcad3ca60f347db"
+  integrity sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==
+
+alien-signals@^3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-3.2.1.tgz#eb66256949bce90b7d30d055e2752e62d6930c7c"
+  integrity sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==
+
+balanced-match@^4.0.2:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
+  integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
+
+birpc@^2.3.0:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/birpc/-/birpc-2.9.0.tgz#b59550897e4cd96a223e2a6c1475b572236ed145"
+  integrity sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==
+
+brace-expansion@^5.0.5:
+  version "5.0.6"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285"
+  integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==
+  dependencies:
+    balanced-match "^4.0.2"
+
+braces@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+  integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+  dependencies:
+    fill-range "^7.1.1"
+
+chokidar@^4.0.0, chokidar@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
+  integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
+  dependencies:
+    readdirp "^4.0.1"
+
+code-block-writer@^13.0.3:
+  version "13.0.3"
+  resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b"
+  integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==
+
+copy-anything@^4:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-4.0.5.tgz#16cabafd1ea4bb327a540b750f2b4df522825aea"
+  integrity sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==
+  dependencies:
+    is-what "^5.2.0"
+
+core-js@~3.47.0:
+  version "3.47.0"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17"
+  integrity sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==
+
+cssesc@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+  integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+csstype@^3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
+  integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
+de-indent@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
+  integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
+
+detect-libc@^2.0.3:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
+  integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
+
+entities@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
+  integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
+
+estree-walker@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+  integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
+fast-glob@^3.3.3:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
+  integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.8"
+
+fastq@^1.6.0:
+  version "1.20.1"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
+  integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==
+  dependencies:
+    reusify "^1.0.4"
+
+fdir@^6.5.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
+  integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+
+fill-range@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+  integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+fsevents@~2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+get-tsconfig@^4.10.1:
+  version "4.14.0"
+  resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz#985d85c52a9903864280ccc2448d413fbf1efed8"
+  integrity sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==
+  dependencies:
+    resolve-pkg-maps "^1.0.0"
+
+glob-parent@^5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+  dependencies:
+    is-glob "^4.0.1"
+
+hash-sum@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-2.0.0.tgz#81d01bb5de8ea4a214ad5d6ead1b523460b0b45a"
+  integrity sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==
+
+he@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
+hookable@^5.5.3:
+  version "5.5.3"
+  resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d"
+  integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==
+
+immutable@^5.1.5:
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165"
+  integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-glob@^4.0.1, is-glob@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-what@^5.2.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/is-what/-/is-what-5.5.0.tgz#a3031815757cfe1f03fed990bf6355a2d3f628c4"
+  integrity sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==
+
+isarray@1.0.0, isarray@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
+
+isobject@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
+  integrity sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==
+  dependencies:
+    isarray "1.0.0"
+
+jiti@^2.6.1:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
+  integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
+
+js-tokens@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+lightningcss-android-arm64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
+  integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
+
+lightningcss-darwin-arm64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
+  integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
+
+lightningcss-darwin-x64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
+  integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
+
+lightningcss-freebsd-x64@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
+  integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
+
+lightningcss-linux-arm-gnueabihf@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
+  integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
+
+lightningcss-linux-arm64-gnu@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
+  integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
+
+lightningcss-linux-arm64-musl@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
+  integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
+
+lightningcss-linux-x64-gnu@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
+  integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
+
+lightningcss-linux-x64-musl@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
+  integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
+
+lightningcss-win32-arm64-msvc@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
+  integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
+
+lightningcss-win32-x64-msvc@1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
+  integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
+
+lightningcss@^1.32.0:
+  version "1.32.0"
+  resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
+  integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
+  dependencies:
+    detect-libc "^2.0.3"
+  optionalDependencies:
+    lightningcss-android-arm64 "1.32.0"
+    lightningcss-darwin-arm64 "1.32.0"
+    lightningcss-darwin-x64 "1.32.0"
+    lightningcss-freebsd-x64 "1.32.0"
+    lightningcss-linux-arm-gnueabihf "1.32.0"
+    lightningcss-linux-arm64-gnu "1.32.0"
+    lightningcss-linux-arm64-musl "1.32.0"
+    lightningcss-linux-x64-gnu "1.32.0"
+    lightningcss-linux-x64-musl "1.32.0"
+    lightningcss-win32-arm64-msvc "1.32.0"
+    lightningcss-win32-x64-msvc "1.32.0"
+
+line-column@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2"
+  integrity sha512-Ktrjk5noGYlHsVnYWh62FLVs4hTb8A3e+vucNZMgPeAOITdshMSgv4cCZQeRDjm7+goqmo6+liZwTXo+U3sVww==
+  dependencies:
+    isarray "^1.0.0"
+    isobject "^2.0.0"
+
+lodash-es@^4.17.21:
+  version "4.18.1"
+  resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
+  integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
+
+lru-cache@^11.1.0:
+  version "11.3.6"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.6.tgz#f0306ad6e9f0a5dc25b16aeba4e8f57b7ec2df55"
+  integrity sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==
+
+magic-string@^0.30.17, magic-string@^0.30.21:
+  version "0.30.21"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
+  integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+  dependencies:
+    "@jridgewell/sourcemap-codec" "^1.5.5"
+
+merge-source-map@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646"
+  integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==
+  dependencies:
+    source-map "^0.6.1"
+
+merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
+  integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
+  dependencies:
+    braces "^3.0.3"
+    picomatch "^2.3.1"
+
+minimatch@^10.0.1:
+  version "10.2.5"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1"
+  integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
+  dependencies:
+    brace-expansion "^5.0.5"
+
+mitt@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
+  integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
+
+muggle-string@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328"
+  integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==
+
+nanoid@^3.3.11:
+  version "3.3.12"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05"
+  integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==
+
+node-addon-api@^7.0.0:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
+  integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
+
+npm-run-path@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-6.0.0.tgz#25cfdc4eae04976f3349c0b1afc089052c362537"
+  integrity sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==
+  dependencies:
+    path-key "^4.0.0"
+    unicorn-magic "^0.3.0"
+
+path-browserify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
+  integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
+
+path-key@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18"
+  integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==
+
+perfect-debounce@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
+  integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
+
+picocolors@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+  integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^2.3.1:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
+  integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
+
+picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
+  integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
+
+pinia@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/pinia/-/pinia-3.0.4.tgz#75dde12784a61e34c1fa6abcd13c1a1061c360c0"
+  integrity sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==
+  dependencies:
+    "@vue/devtools-api" "^7.7.7"
+
+postcss-selector-parser@^7.1.0:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz#e75d2e0d843f620e5df69076166f4e16f891cb9f"
+  integrity sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==
+  dependencies:
+    cssesc "^3.0.0"
+    util-deprecate "^1.0.2"
+
+postcss@^8.5.14, postcss@^8.5.6, postcss@^8.5.8:
+  version "8.5.14"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
+  integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
+  dependencies:
+    nanoid "^3.3.11"
+    picocolors "^1.1.1"
+    source-map-js "^1.2.1"
+
+queue-microtask@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
+readdirp@^4.0.1:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
+  integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
+
+request-light@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/request-light/-/request-light-0.7.0.tgz#885628bb2f8040c26401ebf258ec51c4ae98ac2a"
+  integrity sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==
+
+resolve-pkg-maps@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
+  integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
+
+reusify@^1.0.4:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
+  integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
+
+rfdc@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca"
+  integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==
+
+rolldown@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.1.tgz#2e2e839106dc47951e42dbba414f0f0ecf97ac68"
+  integrity sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==
+  dependencies:
+    "@oxc-project/types" "=0.130.0"
+    "@rolldown/pluginutils" "^1.0.0"
+  optionalDependencies:
+    "@rolldown/binding-android-arm64" "1.0.1"
+    "@rolldown/binding-darwin-arm64" "1.0.1"
+    "@rolldown/binding-darwin-x64" "1.0.1"
+    "@rolldown/binding-freebsd-x64" "1.0.1"
+    "@rolldown/binding-linux-arm-gnueabihf" "1.0.1"
+    "@rolldown/binding-linux-arm64-gnu" "1.0.1"
+    "@rolldown/binding-linux-arm64-musl" "1.0.1"
+    "@rolldown/binding-linux-ppc64-gnu" "1.0.1"
+    "@rolldown/binding-linux-s390x-gnu" "1.0.1"
+    "@rolldown/binding-linux-x64-gnu" "1.0.1"
+    "@rolldown/binding-linux-x64-musl" "1.0.1"
+    "@rolldown/binding-openharmony-arm64" "1.0.1"
+    "@rolldown/binding-wasm32-wasi" "1.0.1"
+    "@rolldown/binding-win32-arm64-msvc" "1.0.1"
+    "@rolldown/binding-win32-x64-msvc" "1.0.1"
+
+run-parallel@^1.1.9:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+  integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+  dependencies:
+    queue-microtask "^1.2.2"
+
+sass@^1.98.0:
+  version "1.99.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.99.0.tgz#ff9d1594da4886249dfaafabbeea2dea2dc74b26"
+  integrity sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==
+  dependencies:
+    chokidar "^4.0.0"
+    immutable "^5.1.5"
+    source-map-js ">=0.6.2 <2.0.0"
+  optionalDependencies:
+    "@parcel/watcher" "^2.4.1"
+
+"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+  integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
+source-map@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+speakingurl@^14.0.1:
+  version "14.0.1"
+  resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53"
+  integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==
+
+superjson@^2.2.2:
+  version "2.2.6"
+  resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.6.tgz#a223a3a988172a5f9656e2063fe5f733af40d099"
+  integrity sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==
+  dependencies:
+    copy-anything "^4"
+
+tiny-invariant@^1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
+  integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
+
+tinyglobby@^0.2.15, tinyglobby@^0.2.16:
+  version "0.2.16"
+  resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
+  integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
+  dependencies:
+    fdir "^6.5.0"
+    picomatch "^4.0.4"
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+ts-md5@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/ts-md5/-/ts-md5-2.0.1.tgz#b05fb6d65f2116aa4591280c8d0510084567f291"
+  integrity sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w==
+
+ts-morph@^26.0.0:
+  version "26.0.0"
+  resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-26.0.0.tgz#d435ccac9421d4615fde8be86fee782f18cd9f73"
+  integrity sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==
+  dependencies:
+    "@ts-morph/common" "~0.27.0"
+    code-block-writer "^13.0.3"
+
+tslib@^2.4.0, tslib@^2.8.0:
+  version "2.8.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+  integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
+typescript@^6.0.2:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
+  integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
+
+unicorn-magic@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104"
+  integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==
+
+util-deprecate@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+vite-plugin-checker@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz#1e9688a5a10f5de1fd833bc1351618eed54db3bc"
+  integrity sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==
+  dependencies:
+    "@babel/code-frame" "^7.27.1"
+    chokidar "^4.0.3"
+    npm-run-path "^6.0.0"
+    picocolors "^1.1.1"
+    picomatch "^4.0.3"
+    tiny-invariant "^1.3.3"
+    tinyglobby "^0.2.15"
+    vscode-uri "^3.1.0"
+
+vite@^8.0.3:
+  version "8.0.13"
+  resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.13.tgz#d75fb40aeee761051b0eb4620993da625c7719ab"
+  integrity sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==
+  dependencies:
+    lightningcss "^1.32.0"
+    picomatch "^4.0.4"
+    postcss "^8.5.14"
+    rolldown "1.0.1"
+    tinyglobby "^0.2.16"
+  optionalDependencies:
+    fsevents "~2.3.3"
+
+vscode-jsonrpc@8.2.0:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9"
+  integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==
+
+vscode-languageserver-protocol@3.17.5, vscode-languageserver-protocol@^3.17.5:
+  version "3.17.5"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea"
+  integrity sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==
+  dependencies:
+    vscode-jsonrpc "8.2.0"
+    vscode-languageserver-types "3.17.5"
+
+vscode-languageserver-textdocument@^1.0.11:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz#457ee04271ab38998a093c68c2342f53f6e4a631"
+  integrity sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==
+
+vscode-languageserver-types@3.17.5:
+  version "3.17.5"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a"
+  integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==
+
+vscode-languageserver@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz#500aef82097eb94df90d008678b0b6b5f474015b"
+  integrity sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==
+  dependencies:
+    vscode-languageserver-protocol "3.17.5"
+
+vscode-uri@^3.0.8, vscode-uri@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c"
+  integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==
+
+vue-demi@>=0.14.10:
+  version "0.14.10"
+  resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
+  integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
+
+vue-router@^4.3.3:
+  version "4.6.4"
+  resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.6.4.tgz#a0a9cb9ef811a106d249e4bb9313d286718020d8"
+  integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==
+  dependencies:
+    "@vue/devtools-api" "^6.6.4"
+
+vue-tsc@^3.2.9:
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.9.tgz#94998ec126c37eef1729daa62331713c30b52d95"
+  integrity sha512-qm8/nbo+9eZc1SCndm9wT+gq23pM+wRIdHY0wjm83B3lIginHTwcdrLUyTrKjDWXbMVNjKegNrnymhpdqnCL3A==
+  dependencies:
+    "@volar/typescript" "2.4.28"
+    "@vue/language-core" "3.2.9"
+
+vue-vine-tsc@^1.7.27:
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/vue-vine-tsc/-/vue-vine-tsc-1.7.27.tgz#9b62c463963785090e5315ab30ac4bd2538d871e"
+  integrity sha512-vd81seHomv2KbQ5BwvoTCu4HDDJyKT7iH1Ug2Ts2nfFma3LbIXNdHY5CxKy5pVVbGvyWfe8gNBwcOgzEG2nI2w==
+  dependencies:
+    "@typescript/native-preview" latest
+    "@volar/language-core" "2.4.20"
+    "@volar/typescript" "2.4.20"
+    "@vue-vine/language-service" "1.7.27"
+    "@vue/language-core" "3.0.3"
+
+vue-vine@^1.7.27:
+  version "1.7.27"
+  resolved "https://registry.yarnpkg.com/vue-vine/-/vue-vine-1.7.27.tgz#04a391116528ccbfeb697d74f068932518af94ca"
+  integrity sha512-tmkOt9xveCmZQMQXWCNztiKLJvu07tTAcWmYLH44RrVAhVSOHPETn4PieSGPt7qOA721q/KF86vIVzpagz+eNg==
+  dependencies:
+    "@vue-vine/rsbuild-plugin" "1.7.27"
+    "@vue-vine/rspack-loader" "1.7.27"
+    "@vue-vine/vite-plugin" "1.7.27"
+
+vue@3.6.0-beta.9:
+  version "3.6.0-beta.9"
+  resolved "https://registry.yarnpkg.com/vue/-/vue-3.6.0-beta.9.tgz#9ae37ed284923456553b7974b60d11b7896834f9"
+  integrity sha512-K/+HT52OAZvaNDDMvT+Lfk36e/97PsyJyDWXM0FDfLQKGbEDXsd4pSQz+BJViGssLcltQLSTlSqQlZ6fkGo5CA==
+  dependencies:
+    "@vue/compiler-dom" "3.6.0-beta.9"
+    "@vue/compiler-sfc" "3.6.0-beta.9"
+    "@vue/runtime-dom" "3.6.0-beta.9"
+    "@vue/runtime-vapor" "3.6.0-beta.9"
+    "@vue/server-renderer" "3.6.0-beta.9"
+    "@vue/shared" "3.6.0-beta.9"