commit fb059ecf83a96aa73edaa8103afd3d25a03426c2
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Fri, 15 May 2026 18:45:30 +0200
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
|
254
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
576
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
62
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
82
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
123
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
438
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
227
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
64
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
268
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
368
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
102
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
799
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
295
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
97
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
160
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
192
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
142
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
148
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
117
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
212
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
808
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
252
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
281
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
240
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
77
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
158
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
115
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
88
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
194
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
107
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
100
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"