From 115051b952fcf27a8ddbd67ba58e8a8d7948d0d5 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Wed, 05 Aug 2026 21:07:08 +0000 Subject: [PATCH] chore: revoke artwork blob urls to save memory --- src/facets/themes/blur-pocket/facet/index.inline.js | 13 +++++++++++++ src/facets/themes/blur/artwork-controller/element.js | 13 +++++++++++++ src/facets/themes/catalogue-one/facet/index.inline.js | 28 ++++++++++++++++++++++++++-- src/facets/themes/tidal/player/element.js | 15 +++++++++++++++ 4 file(s) changed, 67 insertion(s)(+), 2 deletion(s)(-) diff --git a/src/facets/themes/blur-pocket/facet/index.inline.js b/src/facets/themes/blur-pocket/facet/index.inline.js --- a/src/facets/themes/blur-pocket/facet/index.inline.js +++ b/src/facets/themes/blur-pocket/facet/index.inline.js @@ -117,6 +117,7 @@ const miniNext = document.querySelector("#mini-next"); let miniArtUrl = ""; +let miniArtObjectUrl = ""; // Reactively update the mini-player from the controller orchestrator signals. effect(() => { @@ -156,6 +157,14 @@ new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { type: mime }), ); + // Revoke the previous artwork blob URL before replacing it so its bytes + // are released. Otherwise each track leaks one blob URL, growing memory + // until iOS Safari crashes after playing tracks for a while. + if (miniArtObjectUrl) { + URL.revokeObjectURL(miniArtObjectUrl); + } + miniArtObjectUrl = url; + if (miniArt) { miniArt.innerHTML = ""; const img = document.createElement("img"); @@ -166,6 +175,10 @@ }).catch(() => {}); } else { miniArtUrl = ""; + if (miniArtObjectUrl) { + URL.revokeObjectURL(miniArtObjectUrl); + miniArtObjectUrl = ""; + } if (miniArt) { miniArt.innerHTML = ``; } diff --git a/src/facets/themes/blur/artwork-controller/element.js b/src/facets/themes/blur/artwork-controller/element.js --- a/src/facets/themes/blur/artwork-controller/element.js +++ b/src/facets/themes/blur/artwork-controller/element.js @@ -175,6 +175,12 @@ if (!track) { if (currArtwork.current) { + // The `current` URL is promoted to `previous`; the old `previous` + // becomes orphaned, so revoke its blob URL. + if (currArtwork.previous?.url) { + URL.revokeObjectURL(currArtwork.previous.url); + } + this.#artwork.value = { current: null, previous: currArtwork.current }; } @@ -191,6 +197,13 @@ const currTrack = this.currentTrack(); if (track.id === currTrack?.id) { + // The old `previous` (two tracks back) is about to be dropped in favor of + // promoting `current` to `previous`. Revoke its blob URL so unrevoked + // artwork bytes don't accumulate and crash the tab on memory-tight iOS. + if (currArtwork.previous?.url) { + URL.revokeObjectURL(currArtwork.previous.url); + } + this.#artwork.set({ previous: currArtwork.current ? { ...currArtwork.current, loaded: false } diff --git a/src/facets/themes/catalogue-one/facet/index.inline.js b/src/facets/themes/catalogue-one/facet/index.inline.js --- a/src/facets/themes/catalogue-one/facet/index.inline.js +++ b/src/facets/themes/catalogue-one/facet/index.inline.js @@ -257,6 +257,30 @@ let artActive = 0; const MAX_ART_CONCURRENT = 4; +/** Max number of album-art blob URLs kept cached. Beyond this the oldest + * entries are evicted (revoking their blob URLs) so the cache doesn't grow + * without bound and accumulate memory until iOS Safari crashes. */ +const MAX_ART_CACHE = 64; + +/** + * Store an artwork-cache entry, evicting the oldest when the cache overflows + * and revoking evicted blob URLs. + * @param {string} key + * @param {string | null} value + */ +function cacheArt(key, value) { + if (artCache.has(key)) artCache.delete(key); // refresh LRU position + artCache.set(key, value); + + while (artCache.size > MAX_ART_CACHE) { + const oldest = artCache.keys().next().value; + if (oldest === undefined) break; + const evicted = artCache.get(oldest); + artCache.delete(oldest); + if (typeof evicted === "string") URL.revokeObjectURL(evicted); + } +} + /** * Fetch artwork for a given key (album or artist key) using a representative track. * Results are cached as blob URLs (or null if no art found). @@ -293,9 +317,9 @@ if (bytes) { const mime = detectMime(bytes); const url = URL.createObjectURL(new Blob([bytes], { type: mime })); - artCache.set(key, url); + cacheArt(key, url); } else { - artCache.set(key, null); + cacheArt(key, null); } } catch { // don't cache on error — allow retry diff --git a/src/facets/themes/tidal/player/element.js b/src/facets/themes/tidal/player/element.js --- a/src/facets/themes/tidal/player/element.js +++ b/src/facets/themes/tidal/player/element.js @@ -53,6 +53,8 @@ #audioError = signal(false); #isLoading = signal(false); #lastArtKey = /** @type {string | undefined} */ (undefined); + /** @type {string | undefined} */ + #prevArtUrl = undefined; #volumeOpen = signal(false); #lastNonZeroVolume = signal(0.75); /** @type {ReturnType | undefined} */ @@ -116,6 +118,7 @@ : ""; if (!track || !artKey) { + this.#revokeArt(); this.#artUrl.value = undefined; this.#lastArtKey = undefined; return; @@ -128,6 +131,7 @@ this.$artwork.value?.get(track).then((bytes) => { if (this.#lastArtKey !== artKey) return; if (!bytes) { + this.#revokeArt(); this.#artUrl.value = null; return; } @@ -135,6 +139,8 @@ const url = URL.createObjectURL( new Blob([/** @type {BlobPart} */ (bytes)], { type: mime }), ); + this.#revokeArt(); + this.#prevArtUrl = url; this.#artUrl.value = url; }); }); @@ -174,6 +180,7 @@ */ disconnectedCallback() { super.disconnectedCallback(); + this.#revokeArt(); if (this.#isLoadingTimeout) { clearTimeout(this.#isLoadingTimeout); this.#isLoadingTimeout = undefined; @@ -181,6 +188,14 @@ if (this.#volumeCloseTimeout) { clearTimeout(this.#volumeCloseTimeout); this.#volumeCloseTimeout = undefined; + } + } + + /** Revoke the current artwork blob URL, if any. */ + #revokeArt() { + if (this.#prevArtUrl) { + URL.revokeObjectURL(this.#prevArtUrl); + this.#prevArtUrl = undefined; } } -- tangled.sh