diff --git a/src/facets/themes/blur-pocket/facet/index.inline.js b/src/facets/themes/blur-pocket/facet/index.inline.js
index feedce23..94972ed8 100644
--- 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 miniPlayPause = document.querySelector("#mini-play-pause");
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 @@ effect(() => {
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 @@ effect(() => {
}).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
index d53cc064..ebe050cc 100644
--- a/src/facets/themes/blur/artwork-controller/element.js
+++ b/src/facets/themes/blur/artwork-controller/element.js
@@ -175,6 +175,12 @@ class ArtworkController extends DiffuseElement {
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 @@ class ArtworkController extends DiffuseElement {
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
index 0e1206a3..2f1efb98 100644
--- a/src/facets/themes/catalogue-one/facet/index.inline.js
+++ b/src/facets/themes/catalogue-one/facet/index.inline.js
@@ -257,6 +257,30 @@ const artQueue = [];
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 @@ async function doFetchArt(key, track) {
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
index 4f1e1573..484dfc61 100644
--- a/src/facets/themes/tidal/player/element.js
+++ b/src/facets/themes/tidal/player/element.js
@@ -53,6 +53,8 @@ class Player extends DiffuseElement {
#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 @@ class Player extends DiffuseElement {
: "";
if (!track || !artKey) {
+ this.#revokeArt();
this.#artUrl.value = undefined;
this.#lastArtKey = undefined;
return;
@@ -128,6 +131,7 @@ class Player extends DiffuseElement {
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 @@ class Player extends DiffuseElement {
const url = URL.createObjectURL(
new Blob([/** @type {BlobPart} */ (bytes)], { type: mime }),
);
+ this.#revokeArt();
+ this.#prevArtUrl = url;
this.#artUrl.value = url;
});
});
@@ -174,6 +180,7 @@ class Player extends DiffuseElement {
*/
disconnectedCallback() {
super.disconnectedCallback();
+ this.#revokeArt();
if (this.#isLoadingTimeout) {
clearTimeout(this.#isLoadingTimeout);
this.#isLoadingTimeout = undefined;
@@ -184,6 +191,14 @@ class Player extends DiffuseElement {
}
}
+ /** Revoke the current artwork blob URL, if any. */
+ #revokeArt() {
+ if (this.#prevArtUrl) {
+ URL.revokeObjectURL(this.#prevArtUrl);
+ this.#prevArtUrl = undefined;
+ }
+ }
+
// ACTIONS
playPause = () => {