a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: remove custom cache

browser image cache works much better now!

+38 -317
+6 -3
src/index.html
··· 140 140 /> 141 141 </div> 142 142 <div class="modal-actions"> 143 - <button id="clear-db-settings-btn">clear dbs</button> 144 143 <button id="logout-settings-btn" class="danger">logout</button> 145 144 <button id="close-settings-btn">close</button> 146 145 </div> ··· 229 228 <div id="playlists-tree"></div> 230 229 </div> 231 230 <div id="now-playing"> 232 - <img id="cover-art" src="static/tinysub.svg" alt="cover" /> 231 + <img 232 + id="cover-art" 233 + src="static/tinysub.svg" 234 + alt="cover" 235 + loading="lazy" 236 + /> 233 237 <div id="track-info"> 234 238 <div id="track-title">no track playing</div> 235 239 <div id="track-artist"></div> ··· 281 285 <script src="js/selection.js"></script> 282 286 <script src="js/api.js"></script> 283 287 <script src="js/state.js"></script> 284 - <script src="js/image-cache.js"></script> 285 288 <script src="js/queue-storage.js"></script> 286 289 <script src="js/virtual-scroll.js"></script> 287 290 <script src="js/queue.js"></script>
-1
src/js/auth.js
··· 75 75 // clear all storage and reload to logout 76 76 async function handleLogout() { 77 77 try { 78 - await imageCache.clear(); 79 78 if (indexedDB.databases) { 80 79 const dbs = await indexedDB.databases(); 81 80 for (const db of dbs) {
-250
src/js/image-cache.js
··· 1 - // image cache that persists to IndexedDB across sessions 2 - 3 - class ImageCache { 4 - constructor() { 5 - this.cache = new Map(); 6 - this.failed = new Set(); 7 - this.pending = new Map(); 8 - this.queue = []; 9 - this.activeCount = 0; 10 - this.maxConcurrent = 4; 11 - this.pendingImages = new Map(); 12 - this.observer = null; 13 - this.db = null; 14 - } 15 - 16 - // extract cache key from URL query parameters 17 - getKey(url) { 18 - const urlObj = new URL(url); 19 - return `${urlObj.searchParams.get("id")}:${urlObj.searchParams.get("size")}`; 20 - } 21 - 22 - // initialize and return IndexedDB reference 23 - async ensureDB() { 24 - if (this.db) return this.db; 25 - try { 26 - await new Promise((resolve) => { 27 - const req = indexedDB.open("tinysub", 2); 28 - req.onsuccess = () => { 29 - this.db = req.result; 30 - resolve(); 31 - }; 32 - req.onupgradeneeded = (e) => { 33 - const db = e.target.result; 34 - if (!db.objectStoreNames.contains("queueStorage")) { 35 - db.createObjectStore("queueStorage"); 36 - } 37 - if (!db.objectStoreNames.contains("imageCache")) { 38 - db.createObjectStore("imageCache", { keyPath: "key" }); 39 - } 40 - }; 41 - req.onerror = () => resolve(); 42 - }); 43 - } catch (error) { 44 - console.error("[ImageCache]", "IndexedDB initialization failed:", error); 45 - } 46 - return this.db; 47 - } 48 - 49 - // retrieve blob URL from IndexedDB 50 - async getFromDB(key) { 51 - const db = await this.ensureDB(); 52 - if (!db) return null; 53 - return new Promise((resolve) => { 54 - try { 55 - const tx = db.transaction(["imageCache"], "readonly"); 56 - const store = tx.objectStore("imageCache"); 57 - const req = store.get(key); 58 - req.onsuccess = () => { 59 - const result = req.result; 60 - if (result && result.blob) { 61 - const cached = this.cache.get(key); 62 - const blobUrl = cached || URL.createObjectURL(result.blob); 63 - if (!cached) this.cache.set(key, blobUrl); 64 - resolve(blobUrl); 65 - } else { 66 - resolve(null); 67 - } 68 - }; 69 - req.onerror = () => resolve(null); 70 - } catch (error) { 71 - resolve(null); 72 - } 73 - }); 74 - } 75 - 76 - // store blob in IndexedDB 77 - async putInDB(key, blob) { 78 - const db = await this.ensureDB(); 79 - if (!db) return; 80 - return new Promise((resolve) => { 81 - try { 82 - const tx = db.transaction(["imageCache"], "readwrite"); 83 - const store = tx.objectStore("imageCache"); 84 - const req = store.put({ key, blob }); 85 - req.onsuccess = () => resolve(); 86 - req.onerror = () => resolve(); 87 - } catch (error) { 88 - console.error("[ImageCache]", "Failed to store blob in DB:", error); 89 - resolve(); 90 - } 91 - }); 92 - } 93 - 94 - // clear all cached images from IndexedDB 95 - async clearDB() { 96 - if (!this.db) return; 97 - try { 98 - await new Promise((resolve) => { 99 - const tx = this.db.transaction(["imageCache"], "readwrite"); 100 - const store = tx.objectStore("imageCache"); 101 - const req = store.clear(); 102 - req.onsuccess = () => resolve(); 103 - req.onerror = () => resolve(); 104 - }); 105 - } catch (error) { 106 - console.error("[ImageCache]", "Failed to clear DB:", error); 107 - } 108 - } 109 - 110 - // get cached image or queue for fetching 111 - async get(url) { 112 - const key = this.getKey(url); 113 - if (this.cache.has(key)) return this.cache.get(key); 114 - if (this.failed.has(key)) return url; 115 - if (this.pending.has(key)) return this.pending.get(key); 116 - 117 - // create pending promise to prevent concurrent DB fetches for same key 118 - const promise = (async () => { 119 - const fromDB = await this.getFromDB(key); 120 - if (fromDB) return fromDB; 121 - 122 - return new Promise((resolve) => { 123 - this.queue.push({ url, key, resolve }); 124 - this.processQueue(); 125 - }); 126 - })(); 127 - 128 - this.pending.set(key, promise); 129 - promise.finally(() => this.pending.delete(key)); 130 - return promise; 131 - } 132 - 133 - // process pending fetch requests with concurrency control 134 - async processQueue() { 135 - while (this.activeCount < this.maxConcurrent && this.queue.length > 0) { 136 - const { url, key, resolve } = this.queue.shift(); 137 - this.activeCount++; 138 - try { 139 - resolve(await this.fetch(url, key)); 140 - } finally { 141 - this.activeCount--; 142 - } 143 - } 144 - } 145 - 146 - // fetch image from network and cache it 147 - async fetch(url, key) { 148 - try { 149 - const r = await fetch(url); 150 - if (!r.ok) throw new Error(`HTTP ${r.status}`); 151 - const blob = await r.blob(); 152 - const blobUrl = URL.createObjectURL(blob); 153 - 154 - const oldUrl = this.cache.get(key); 155 - if (oldUrl) URL.revokeObjectURL(oldUrl); 156 - 157 - this.cache.set(key, blobUrl); 158 - 159 - this.putInDB(key, blob); 160 - return blobUrl; 161 - } catch (error) { 162 - this.failed.add(key); 163 - return url; 164 - } 165 - } 166 - 167 - // clear all cache and stop observing 168 - async clear() { 169 - this.cache.forEach((blobUrl) => URL.revokeObjectURL(blobUrl)); 170 - this.cache.clear(); 171 - this.failed.clear(); 172 - this.pending.clear(); 173 - this.queue = []; 174 - this.activeCount = 0; 175 - this.pendingImages.clear(); 176 - if (this.observer) this.observer.disconnect(); 177 - this.observer = null; 178 - await this.clearDB(); 179 - } 180 - 181 - // initialize IntersectionObserver for lazy-loading 182 - initObserver() { 183 - if (this.observer) return; 184 - this.observer = new IntersectionObserver( 185 - (entries) => { 186 - entries.forEach((entry) => { 187 - if (entry.isIntersecting) { 188 - const loadFn = this.pendingImages.get(entry.target); 189 - if (loadFn) { 190 - loadFn(); 191 - this.pendingImages.delete(entry.target); 192 - this.observer.unobserve(entry.target); 193 - } 194 - } 195 - }); 196 - }, 197 - { rootMargin: "48px" }, 198 - ); 199 - } 200 - 201 - // queue image for lazy-loading when it enters viewport 202 - observeImage(img, loadFn) { 203 - this.initObserver(); 204 - this.pendingImages.set(img, loadFn); 205 - this.observer.observe(img); 206 - } 207 - 208 - // check if all URLs are cached; returns array or null 209 - cachedUrls(urls) { 210 - const cached = urls.map((url) => this.cache.get(this.getKey(url))); 211 - return cached.every((c) => c) ? cached : null; 212 - } 213 - } 214 - 215 - const imageCache = new ImageCache(); 216 - 217 - // check if art is enabled in settings 218 - function shouldShowArt(artType, artId) { 219 - const setting = state.settings[artType]; 220 - return setting > 0 ? artId : null; 221 - } 222 - 223 - // load image with appropriate device density 224 - function loadCachedImage(img, artId, artType) { 225 - const size1x = Math.max(state.settings[artType], 16); 226 - const url1x = state.api.getCoverArtUrl(artId, size1x); 227 - const urls = 228 - (window.devicePixelRatio || 1) > 1 229 - ? [url1x, state.api.getCoverArtUrl(artId, size1x * 2)] 230 - : [url1x]; 231 - 232 - // sync cache check 233 - const cached = imageCache.cachedUrls(urls); 234 - if (cached) { 235 - img.src = cached[0]; 236 - if (cached.length > 1) img.srcset = `${cached[0]} 1x, ${cached[1]} 2x`; 237 - return; 238 - } 239 - 240 - // async lazy load on viewport entry 241 - imageCache.observeImage(img, async () => { 242 - try { 243 - const srcs = await Promise.all(urls.map((url) => imageCache.get(url))); 244 - img.src = srcs[0]; 245 - if (srcs.length > 1) img.srcset = `${srcs[0]} 1x, ${srcs[1]} 2x`; 246 - } catch (error) { 247 - // network error, img shows placeholder 248 - } 249 - }); 250 - }
+8 -3
src/js/library.js
··· 223 223 const linkChildren = []; 224 224 if (coverArtId) { 225 225 const imgEl = createElement("img"); 226 - loadCachedImage(imgEl, coverArtId, artType); 226 + const size = state.settings[artType]; 227 + const url1x = state.api.getCoverArtUrl(coverArtId, size); 228 + const url2x = state.api.getCoverArtUrl(coverArtId, size * 2); 229 + imgEl.src = url1x; 230 + imgEl.loading = "lazy"; 231 + imgEl.srcset = `${url1x} 1x, ${url2x} 2x`; 227 232 linkChildren.push(imgEl); 228 233 } 229 234 linkChildren.push(createElement("span", { textContent: name })); ··· 369 374 artType: "artAlbum", 370 375 itemMapper: (album) => ({ 371 376 label: album.name, 372 - cover: shouldShowArt("artAlbum", album.coverArt), 377 + cover: state.settings.artAlbum > 0 ? album.coverArt : null, 373 378 isExpanded: state.expanded.items.albums[album.id], 374 379 }), 375 380 onToggle: (album, li) => { ··· 413 418 artType: "artArtist", 414 419 itemMapper: (artist) => ({ 415 420 label: artist.title || artist.name, 416 - cover: shouldShowArt("artArtist", artist.coverArt), 421 + cover: state.settings.artArtist > 0 ? artist.coverArt : null, 417 422 }), 418 423 onToggle: (artist, li) => { 419 424 const map = state.expanded.items.artists;
+18 -36
src/js/player.js
··· 1 1 // audio playback control and player state management 2 2 3 - // fixed resolution for cover art display 3 + // fixed resolution for cover art, favicon, and media session 4 4 // why? the media session cover art has to be a pretty decent resolution to look good on phones (and hidpi), and if there were a bunch of separate sizes then we are just downloading more images for no good reason 5 5 const FIXED_ART_SIZE = 512; 6 6 7 - // update favicon with song art or default icon 7 + // update favicon 8 8 function updateFavicon(artId) { 9 9 const link = document.querySelector('link[rel="icon"]'); 10 10 if (!link) return; 11 11 if (state.settings.dynamicFavicon && artId) { 12 12 const url = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 13 - imageCache.get(url).then((src) => { 14 - link.href = src; 15 - link.type = "image/jpeg"; 16 - }); 13 + link.href = url; 14 + link.type = "image/jpeg"; 17 15 } else { 18 16 link.href = ICONS.TINYSUB; 19 17 link.type = "image/svg+xml"; ··· 33 31 // Prefer album art (albumId) over individual song art (coverArt) 34 32 const artId = song.albumId || song.coverArt; 35 33 updateFavicon(artId); 34 + 36 35 if (artId) { 37 - const url = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 38 - imageCache.get(url).then((src) => { 39 - ui.coverArt.src = src; 40 - }); 36 + ui.coverArt.src = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 41 37 } else { 42 38 ui.coverArt.src = ""; 43 - ui.coverArt.srcset = ""; 44 39 } 45 40 ui.player.src = state.api.getStreamUrl(song.id); 46 41 if (autoplay) { ··· 200 195 if (!song) return; 201 196 // Prefer album art (albumId) over individual song art (coverArt) 202 197 const artId = song.albumId || song.coverArt; 203 - const artwork = []; 204 - if (artId) { 205 - const url = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 206 - // pre-cache the image 207 - imageCache.get(url).then((cachedUrl) => { 208 - withMediaSession((ms) => { 209 - ms.metadata = new MediaMetadata({ 210 - title: song.title || STRINGS.UNKNOWN_TRACK, 211 - artist: song.artist || STRINGS.UNKNOWN_ARTIST, 212 - album: song.album || "", 213 - artwork: [ 198 + 199 + withMediaSession((ms) => { 200 + ms.metadata = new MediaMetadata({ 201 + title: song.title || STRINGS.UNKNOWN_TRACK, 202 + artist: song.artist || STRINGS.UNKNOWN_ARTIST, 203 + album: song.album || "", 204 + artwork: artId 205 + ? [ 214 206 { 215 - src: cachedUrl, 207 + src: state.api.getCoverArtUrl(artId, FIXED_ART_SIZE), 216 208 sizes: "512x512", 217 209 type: "image/jpeg", 218 210 }, 219 - ], 220 - }); 221 - }); 222 - }); 223 - } else { 224 - withMediaSession((ms) => { 225 - ms.metadata = new MediaMetadata({ 226 - title: song.title || STRINGS.UNKNOWN_TRACK, 227 - artist: song.artist || STRINGS.UNKNOWN_ARTIST, 228 - album: song.album || "", 229 - artwork: [], 230 - }); 211 + ] 212 + : [], 231 213 }); 232 - } 214 + }); 233 215 };
-4
src/js/queue-storage.js
··· 22 22 if (!db.objectStoreNames.contains("queueStorage")) { 23 23 db.createObjectStore("queueStorage"); 24 24 } 25 - if (!db.objectStoreNames.contains("imageCache")) { 26 - db.createObjectStore("imageCache", { keyPath: "key" }); 27 - } 28 25 }; 29 26 30 27 req.onerror = () => resolve(null); ··· 34 31 return null; 35 32 } 36 33 } 37 - 38 34 async save(songs, queueIndex) { 39 35 const db = await this.ensureDB(); 40 36 if (!db) return;
+6 -1
src/js/queue.js
··· 408 408 if (artId && state.settings.artSong > 0) { 409 409 const img = document.createElement("img"); 410 410 img.className = CLASSES.QUEUE_COVER; 411 + const size = state.settings.artSong; 412 + const url1x = state.api.getCoverArtUrl(artId, size); 413 + const url2x = state.api.getCoverArtUrl(artId, size * 2); 414 + img.src = url1x; 415 + img.loading = "lazy"; 416 + img.srcset = `${url1x} 1x, ${url2x} 2x`; 411 417 coverCell.appendChild(img); 412 - loadCachedImage(img, artId, "artSong"); 413 418 } 414 419 cells.appendChild(coverCell); 415 420
-19
src/js/settings.js
··· 102 102 } 103 103 }); 104 104 105 - // clear dbs button 106 - const clearCacheBtn = document.getElementById("clear-db-settings-btn"); 107 - if (clearCacheBtn) { 108 - clearCacheBtn.addEventListener("click", async () => { 109 - try { 110 - if (indexedDB.databases) { 111 - const dbs = await indexedDB.databases(); 112 - for (const db of dbs) { 113 - indexedDB.deleteDatabase(db.name); 114 - } 115 - } 116 - alert("local databases cleared"); 117 - location.reload(); 118 - } catch (error) { 119 - console.error("[Settings] Failed to clear databases:", error); 120 - } 121 - }); 122 - } 123 - 124 105 // logout button 125 106 const logoutBtn = document.getElementById("logout-settings-btn"); 126 107 if (logoutBtn) {