diff --git a/src/components/artwork/audio-metadata/worker.js b/src/components/artwork/audio-metadata/worker.js index f2bc026f..36e0105c 100644 --- a/src/components/artwork/audio-metadata/worker.js +++ b/src/components/artwork/audio-metadata/worker.js @@ -3,16 +3,22 @@ import { musicMetadataTags } from "~/components/metadata/common.js"; import { ostiary, rpc, workerProxy } from "~/common/worker.js"; /** - * @import {Extraction} from "@specs/components/metadata/audio-file/types.d.ts" * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" - * @import {InputActions} from "@specs/components/input/types.d.ts" - * @import {Actions} from "@specs/components/artwork/types.d.ts" + * @import {InputActions} from "~/components/input/types.d.ts" + * @import {Actions} from "~/components/artwork/types.d.ts" */ //////////////////////////////////////////// // ACTIONS //////////////////////////////////////////// +/** + * Time budget before this provider aborts the underlying HTTP extraction. + * On timeout, in-flight requests are cancelled so a slow source doesn't keep + * occupying server resources (or hold up the configurator chain). + */ +const PROVIDER_TIMEOUT_MS = 60_000; + /** * @type {ActionsWithTunnel['get']} */ @@ -23,37 +29,52 @@ export async function get({ data: track, ports }) { return ports.input; }); - const resGet = await input.resolve({ method: "GET", uri: track.uri }); - if (!resGet) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROVIDER_TIMEOUT_MS); - const resHead = "stream" in resGet - ? undefined - : await input.resolve({ method: "HEAD", uri: track.uri }); + try { + const resGet = await input.resolve({ method: "GET", uri: track.uri }); + if (!resGet) { + // Couldn't obtain the stream/url — a transient input failure, NOT a + // confirmation that the track has no artwork. Throw so the artwork + // configurator tries the next provider (e.g. an online source) instead of + // letting us report "no art" which the orchestrator would latch forever. + throw new Error("audio-metadata: input.resolve returned no result"); + } - const trackUri = URI.parse(track.uri); - const trackPathParts = trackUri.path?.split("/"); - const filename = trackPathParts?.[trackPathParts.length - 1]; + const resHead = "stream" in resGet + ? undefined + : await input.resolve({ method: "HEAD", uri: track.uri }); - const meta = await musicMetadataTags({ - filename, - includeArtwork: true, - stream: "stream" in resGet ? resGet.stream : undefined, - mimeType: "stream" in resGet ? resGet.mimeType : undefined, - urls: "url" in resGet - ? { - get: resGet.url, - head: resHead && "url" in resHead ? resHead.url : resGet.url, - } - : undefined, - }).catch(/** @param {Error} err */ (err) => { - console.error("music-metadata error", err); - return /** @type {Extraction} */ ({}); - }); + const trackUri = URI.parse(track.uri); + const trackPathParts = trackUri.path?.split("/"); + const filename = trackPathParts?.[trackPathParts.length - 1]; + + const meta = await musicMetadataTags({ + filename, + includeArtwork: true, + stream: "stream" in resGet ? resGet.stream : undefined, + mimeType: "stream" in resGet ? resGet.mimeType : undefined, + urls: "url" in resGet + ? { + get: resGet.url, + head: resHead && "url" in resHead ? resHead.url : resGet.url, + } + : undefined, + signal: controller.signal, + }); + + // NOTE: no `.catch` here. If extraction fails (or is aborted) it throws, + // which lets the configurator fall through to the next provider rather than + // treating a processing failure as "this track has no artwork" - const pictures = meta.artwork ?? []; - if (!pictures.length) return null; + const pictures = meta.artwork ?? []; + if (!pictures.length) return null; - return pictures[0].data; + return pictures[0].data; + } finally { + clearTimeout(timer); + } } //////////////////////////////////////////// diff --git a/src/components/artwork/last.fm/worker.js b/src/components/artwork/last.fm/worker.js index d2c8c0ed..f98f1088 100644 --- a/src/components/artwork/last.fm/worker.js +++ b/src/components/artwork/last.fm/worker.js @@ -8,6 +8,13 @@ import { ostiary, rpc } from "~/common/worker.js"; // ACTIONS //////////////////////////////////////////// +/** + * Time budget for this provider's own HTTP requests. Once exceeded, in-flight + * fetches are aborted so a slow/cancelled request doesn't keep occupying server + * resources (and hold up the caller). + */ +const PROVIDER_TIMEOUT_MS = 60_000; + /** * @type {Actions['get']} */ @@ -17,15 +24,19 @@ export async function get(track) { const query = track.tags?.artist; if (!query) return null; - return await fetch( - `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, - ) - .then((r) => r.json()) - .then((r) => findCover(r.results.albummatches.album)) - .catch((err) => { - console.error(err); - return null; - }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROVIDER_TIMEOUT_MS); + const signal = controller.signal; + + try { + const search = await fetch( + `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, + { signal }, + ).then((r) => r.json()); + return await findCover(search.results.albummatches.album, signal); + } finally { + clearTimeout(timer); + } } //////////////////////////////////////////// @@ -42,18 +53,19 @@ ostiary((context) => { /** * @param {any[]} remainingMatches + * @param {AbortSignal} signal * @returns {Promise} */ -async function findCover(remainingMatches) { +async function findCover(remainingMatches, signal) { const album = remainingMatches[0]; const url = album ? album.image[album.image.length - 1]["#text"] : null; return url && url !== "" - ? await fetch(url) + ? await fetch(url, { signal }) .then((r) => r.blob()) .then(async (b) => new Uint8Array(await b.arrayBuffer())) - .catch(() => findCover(remainingMatches.slice(1))) + .catch(() => findCover(remainingMatches.slice(1), signal)) : album - ? findCover(remainingMatches.slice(1)) + ? findCover(remainingMatches.slice(1), signal) : null; } diff --git a/src/components/artwork/musicbrainz/worker.js b/src/components/artwork/musicbrainz/worker.js index 7f1d5e97..27afeae1 100644 --- a/src/components/artwork/musicbrainz/worker.js +++ b/src/components/artwork/musicbrainz/worker.js @@ -9,6 +9,13 @@ import { ostiary, rpc } from "~/common/worker.js"; // ACTIONS //////////////////////////////////////////// +/** + * Time budget for this provider's own HTTP requests. Once exceeded, in-flight + * fetches are aborted so a slow/cancelled request doesn't keep occupying server + * resources (and hold up the caller). + */ +const PROVIDER_TIMEOUT_MS = 60_000; + /** * @type {Actions['get']} */ @@ -21,7 +28,13 @@ export async function get(track) { const variousArtists = artist?.toUpperCase() === "VA"; - return search(track, variousArtists); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROVIDER_TIMEOUT_MS); + try { + return await search(track, variousArtists, controller.signal); + } finally { + clearTimeout(timer); + } } //////////////////////////////////////////// @@ -72,9 +85,10 @@ function escapeLucene(str) { /** * @param {Track} track * @param {boolean} variousArtists + * @param {AbortSignal} signal * @returns {Promise} */ -async function search(track, variousArtists) { +async function search(track, variousArtists, signal) { const artist = track.tags?.artist; const album = track.tags?.album; @@ -82,27 +96,27 @@ async function search(track, variousArtists) { (variousArtists ? `` : ` AND artistname:"${escapeLucene(artist || "")}"`); const encodedQuery = encodeURIComponent(query); - return await fetch( + const r = await fetch( `https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`, - ) - .then((r) => r.json()) - .then((r) => { - if (r.releases.length === 0 && !variousArtists) { - return search(track, true); - } else { - return findCover(r.releases, track, variousArtists); - } - }) - .catch(() => null); + { signal }, + ).then((r) => r.json()).catch(() => undefined); + + if (!r) return null; + if (r.releases.length === 0 && !variousArtists) { + return search(track, true, signal); + } else { + return findCover(r.releases, track, variousArtists, signal); + } } /** * @param {any[]} remainingReleases * @param {Track} track * @param {boolean} variousArtists + * @param {AbortSignal} signal * @returns {Promise} */ -async function findCover(remainingReleases, track, variousArtists) { +async function findCover(remainingReleases, track, variousArtists, signal) { const release = remainingReleases[0]; if (!release) return null; @@ -114,17 +128,17 @@ async function findCover(remainingReleases, track, variousArtists) { return await fetch( `https://coverartarchive.org/release/${release.id}/front-1200`, + { signal }, ) .then((r) => r.blob()) .then(async (b) => { if (b.type.startsWith("image/")) { return new Uint8Array(await b.arrayBuffer()); } else { - return findCover(remainingReleases.slice(1), track, variousArtists); + return findCover(remainingReleases.slice(1), track, variousArtists, signal); } }) - .catch((err) => { - console.error(err); - return findCover(remainingReleases.slice(1), track, variousArtists); - }); + .catch(() => + findCover(remainingReleases.slice(1), track, variousArtists, signal) + ); } diff --git a/src/components/configurator/artwork/worker.js b/src/components/configurator/artwork/worker.js index a5e94563..7da5d6af 100644 --- a/src/components/configurator/artwork/worker.js +++ b/src/components/configurator/artwork/worker.js @@ -10,20 +10,76 @@ import { ostiary, rpc, workerProxy } from "~/common/worker.js"; // ACTIONS //////////////////////////////////////////// +/** + * Time budget for a single artwork retrieval component (provider). A provider + * that takes longer than this is skipped and the chain falls through to the + * next one, so one slow source can't hold up the whole lookup. + */ +const PROVIDER_TIMEOUT_MS = 60_000; + +/** + * When a provider fails on a long-running request (times out, or rejects), we + * "open the circuit" for it for this long: subsequent requests skip that + * provider entirely and fall straight through to the next one, instead of + * repeatedly hammering a source that's currently failing. + */ +const PROVIDER_COOLDOWN_MS = 5 * 60_000; + +/** + * A short sentinel so the provider result is distinguishable from a real + * `null` "no art" reply. + */ +const TIMED_OUT = "__PROVIDER_TIMEOUT__"; + +/** + * providerKey → timestamp (ms) until which that provider is cooled down. + */ +/** @type {Map} */ +const cooldownUntil = new Map(); + /** * @type {ActionsWithTunnel['get']} */ export async function get({ data, ports }) { const track = data; - for (const port of Object.values(ports)) { + const now = Date.now(); + for (const [key, port] of Object.entries(ports)) { + // Circuit breaker: if this provider recently failed a long-running request, + // skip it entirely (no invocation at all) and try the next source. Expired + // entries simply fall through and get invoked again (and re-cooled if they + // fail again). + const until = cooldownUntil.get(key); + if (until !== undefined && until > now) continue; + /** @type {ProxiedActions} */ const artwork = workerProxy(() => { port.start(); return port; }); - const bytes = await artwork.get(track); + // Race each provider against its own time budget instead of wrapping the + // whole chain. If it doesn't answer in time, treat it like a failure and + // fall through to the next configured source. + const deadline = new Promise((resolve) => + setTimeout(() => resolve(TIMED_OUT), PROVIDER_TIMEOUT_MS) + ); + + let bytes; + let failed = false; + try { + bytes = await Promise.race([artwork.get(track), deadline]); + if (bytes === TIMED_OUT) failed = true; + } catch { + bytes = TIMED_OUT; + failed = true; + } + + if (failed) { + // A long-running request failed — open the circuit for a while. + cooldownUntil.set(key, Date.now() + PROVIDER_COOLDOWN_MS); + continue; + } if (bytes !== null) return bytes; } diff --git a/src/components/metadata/common.js b/src/components/metadata/common.js index 8fb5f6e4..b7616745 100644 --- a/src/components/metadata/common.js +++ b/src/components/metadata/common.js @@ -59,7 +59,7 @@ function mimeTypeToPath(mimeType) { } /** - * @param {{ includeArtwork?: boolean; filename?: string; mimeType?: string; stream?: ReadableStream; urls?: Urls; }} _ + * @param {{ includeArtwork?: boolean; filename?: string; mimeType?: string; stream?: ReadableStream; urls?: Urls; signal?: AbortSignal; }} _ * @returns {Promise} */ export async function musicMetadataTags({ @@ -68,6 +68,7 @@ export async function musicMetadataTags({ mimeType, stream, urls, + signal, }) { const uri = urls ? URI.parse(urls.get) : undefined; const pathParts = uri?.path?.split("/"); @@ -76,7 +77,7 @@ export async function musicMetadataTags({ let meta; if (urls?.get.startsWith("blob:")) { - const blob = await fetch(urls.get).then((r) => r.blob()); + const blob = await fetch(urls.get, { signal }).then((r) => r.blob()); // Blob URLs carry no filename, so the URL path is just the blob's UUID. // Without a path or a recognised MIME-type music-metadata falls back to // content-sniffing, whose content-type matcher is broken in the browser @@ -98,14 +99,28 @@ export async function musicMetadataTags({ httpClient.resolvedUrl = urls.get; const getHeadInfo = httpClient.getHeadInfo; - // FUCKAROUND: Not sure of the downsides of this + // Link the caller's abort signal to the range-reading HttpClient so an + // artwork timeout can cancel the underlying HTTP request (instead of + // leaving it running and consuming server resources). + const onAbort = () => httpClient.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + + // Preserve the real `acceptPartialRequests` from the server's HEAD response + // instead of forcing it true. `@tokenizer/http` already parses + // `Accept-Ranges`/`Content-Range` and reports partial support correctly; + // overriding it to `true` on a server that does NOT actually support ranges + // makes `@tokenizer/range` read the whole stream to locate metadata/artwork, + // which turns a one-millisecond tail read into a minutes-long full download + // (stalling cover art behind it). Keep only the Content-Length fallback so + // servers that don't return a length still resolve a (full-download) value. /** @type {any} */ (httpClient).getHeadInfo = async () => { try { const info = await getHeadInfo.call(httpClient); - return { ...info, acceptPartialRequests: true }; + if (Number.isFinite(info?.size)) return info; + // No Content-Length — fall back to a full read without pretending + // range support. + return { size: undefined, acceptPartialRequests: false }; } catch { - // Some servers (e.g. Dropbox temporary links) don't return Content-Length. - // Fall back to downloading the full file without range requests. return { size: undefined, acceptPartialRequests: false }; } }; @@ -126,6 +141,7 @@ export async function musicMetadataTags({ tokenizer.fileInfo = { ...tokenizer.fileInfo, path }; } meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); + signal?.removeEventListener("abort", onAbort); } else if (stream) { meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork, diff --git a/src/components/orchestrator/artwork/worker.js b/src/components/orchestrator/artwork/worker.js index 0e457fba..d853c785 100644 --- a/src/components/orchestrator/artwork/worker.js +++ b/src/components/orchestrator/artwork/worker.js @@ -81,7 +81,13 @@ async function processRequest(track, ports) { return ports.artwork; }); - const bytes = await configurator.get(track); + let bytes; + + try { + bytes = await configurator.get(track); + } catch { + return null; + } if (bytes === null) { await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${track.id}`, []); diff --git a/src/facets/themes/catalogue-one/facet/index.inline.js b/src/facets/themes/catalogue-one/facet/index.inline.js index 2f1efb98..19d6682e 100644 --- a/src/facets/themes/catalogue-one/facet/index.inline.js +++ b/src/facets/themes/catalogue-one/facet/index.inline.js @@ -257,28 +257,26 @@ 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; +/** + * Grid cards and queue items carry a `data-art-key` container whose cover is + * filled lazily; they do NOT render their own content inside the template so + * lit-html never tracks their internals. Detail header and player-bar art are + * rendered eagerly by their own templates instead, so they're excluded here. + */ +const ART_CONTAINER_SELECTOR = + ".cat-card__art[data-art-key], .cat-queue__item-art[data-art-key]"; /** - * Store an artwork-cache entry, evicting the oldest when the cache overflows - * and revoking evicted blob URLs. + * Store an artwork-cache entry for `key`. The cache is unbounded: covers are + * small and their blob data is released by GC once the referencing `` + * leaves the DOM (matching the `blur` theme, which keeps every cover URL with + * no eviction or `revokeObjectURL`). * @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); - } } /** @@ -310,10 +308,10 @@ function drainArtQueue() { */ async function doFetchArt(key, track) { try { - const timeout = new Promise((resolve) => - setTimeout(() => resolve(null), 15_000) - ); - const bytes = await Promise.race([artwork.get(track), timeout]); + // Bounded upstream (orchestrator drops a cover after 60s and clears its + // in-flight entry), so a hanging download can't wedge the queue: this + // promise always settles, and each slot is always released. + const bytes = await artwork.get(track); if (bytes) { const mime = detectMime(bytes); const url = URL.createObjectURL(new Blob([bytes], { type: mime })); @@ -328,18 +326,98 @@ async function doFetchArt(key, track) { artActive--; drainArtQueue(); } - scheduleArtRender(); + if (artCache.get(key)) renderArtForKey(key); } -/** Re-render to show newly loaded artwork. */ -let artRenderRaf = false; -function scheduleArtRender() { - if (artRenderRaf) return; - artRenderRaf = true; - requestAnimationFrame(() => { - artRenderRaf = false; - renderContent(); - }); +/** + * Swap a freshly fetched cover into every rendered grid card / queue item for + * `key` so the image appears in place without re-rendering the whole list + * (which would rebuild the DOM and could reset the scroll position). Detail + * header and player-bar art are rendered eagerly by their own templates. + * @param {string} key + */ +function renderArtForKey(key) { + const url = artCache.get(key); + if (!url) return; + // Match by attribute value, not by interpolating `key` into a CSS selector. + // Album/artist names contain arbitrary chars (' " \ ...) that would make + // `[data-art-key="${key}"]` an invalid selector and throw, silently + // breaking the cover swap for that item. + for (const node of document.querySelectorAll(ART_CONTAINER_SELECTOR)) { + if ((/** @type {HTMLElement} */ (node)).dataset.artKey !== key) continue; + renderArtForContainer(/** @type {HTMLElement} */ (node)); + } +} + +/** + * Observe artwork containers that are (near) visible so we only fetch artwork + * for items that are actually on screen. Elements are tagged with + * `data-art-key` and `data-art-track-id` at render time (without fetching); + * when one scrolls into view we fetch just that item's artwork. Rooted at the + * document viewport so both the scrolled `.cat-content` panel and the queue + * panel are covered (their own `overflow` clipping still correctly reports + * non-intersecting items). Only intersecting items enter the fetch queue, so + * we never load artwork for cards that aren't (about to be) visible. + */ +/** @type {IntersectionObserver | undefined} */ +let artObserver = undefined; + +/** Cards that have scrolled into view, pending a debounced batch fetch. */ +/** @type {Map} */ +const pendingVisibleArt = new Map(); +/** @type {ReturnType | undefined} */ +let artFetchDebounce = undefined; + +function armArtObserver() { + artObserver?.disconnect(); + artObserver = undefined; + // Drop any pending batch from the previous view so we don't fetch artwork + // for cards that are no longer shown. + clearTimeout(artFetchDebounce); + artFetchDebounce = undefined; + pendingVisibleArt.clear(); + + artObserver = new IntersectionObserver( + (entries) => { + let hasNew = false; + for (const entry of entries) { + const target = /** @type {HTMLElement} */ (entry.target); + if (!entry.isIntersecting) continue; + const key = target.dataset.artKey; + if (!key || artCache.has(key) || pendingVisibleArt.has(key)) continue; + const trackId = target.dataset.artTrackId; + const track = trackId ? findTrack(trackId) : undefined; + // Do NOT unobserve here. If the fetch for this card later fails or is + // dropped (timeout, transient error, or a re-arm clearing the pending + // batch), leaving it observed lets the next scroll re-entry retry it. + // Otherwise a failed cover would be unobserved and never attempted + // again, which is why covers could stop appearing after a while. + if (key && track) { + pendingVisibleArt.set(key, track); + hasNew = true; + } + } + if (!hasNew) return; + + // Batch fetches so a rapid scroll doesn't dispatch one fetch per card. + clearTimeout(artFetchDebounce); + artFetchDebounce = setTimeout(() => { + for (const [key, track] of pendingVisibleArt) { + fetchArt(key, track); + } + pendingVisibleArt.clear(); + }, 150); + }, + { rootMargin: "200px" }, + ); + + for (const target of document.querySelectorAll(ART_CONTAINER_SELECTOR)) { + const elTarget = /** @type {HTMLElement} */ (target); + const key = elTarget.dataset.artKey; + if (!key) continue; + if (artCache.has(key) || pendingArt.has(key)) continue; + artObserver.observe(elTarget); + } } //////////////////////////////////////////// @@ -524,17 +602,62 @@ const artPlaceholder = html` `; +const queueArtPlaceholder = html` +
+ +
+`; + /** * @param {string} key * @param {Track} track */ function artBlock(key, track) { - fetchArt(key, track); + // Artwork is not fetched here — the card is tagged so `armArtObserver` can + // request artwork lazily once it scrolls into view. The container is rendered + // empty (no lit-html content part) so its children can be inserted/replaced + // freely by `renderArtForContainer` without corrupting lit-html's committed + // part nodes. + return html` +
+ `; +} + +/** + * Render (or refresh) the art content of a single `data-art-key` container: + * the cached cover image if available, otherwise the placeholder. Rendered + * through lit-html so updates stay consistent with its internal bookkeeping, + * and the placeholder variant is chosen to match the container type (grid card + * vs queue item). + * @param {HTMLElement} container + */ +function renderArtForContainer(container) { + const key = container.dataset.artKey; + if (!key) return; const url = artCache.get(key); if (url) { - return html``; + litRender(html``, container); + return; + } + const placeholder = container.classList.contains("cat-queue__item-art") + ? queueArtPlaceholder + : artPlaceholder; + litRender(placeholder, container); +} + +/** + * Ensure every rendered art container shows a placeholder when its cover isn't + * cached yet. Run after each render so fresh cards show the placeholder icon + * immediately. + */ +function renderArtPlaceholders() { + for (const node of document.querySelectorAll(ART_CONTAINER_SELECTOR)) { + renderArtForContainer(/** @type {HTMLElement} */ (node)); } - return artPlaceholder; } /** @@ -686,7 +809,7 @@ function renderDetailView() {
-
+
${detailArtBlock(artKey, firstTrack)}
@@ -776,12 +899,15 @@ function renderContent() { if (detail) { litRender(renderDetailView(), el.content); + armArtObserver(); return; } if (mode === "albums") litRender(renderAlbumGrid(), el.content); else if (mode === "artists") litRender(renderArtistGrid(), el.content); else litRender(renderSongList(), el.content); + renderArtPlaceholders(); + armArtObserver(); } //////////////////////////////////////////// @@ -810,22 +936,11 @@ function renderQueue() { : ""}" @click=${() => playAtQueueIndex(flatIndex)} > -
- ${track - ? (() => { - const key = albumOf(track).toLowerCase(); - fetchArt(key, track); - const url = artCache.get(key); - return url ? html`` : html` -
- `; - })() - : html` -
- `} -
+
${title}
${artist}
@@ -902,6 +1017,8 @@ function renderQueuePanel() { el.queueToggle.setAttribute("data-active", isOpen ? "t" : "f"); if (!isOpen) return; litRender(renderQueue(), el.queuePanel); + renderArtPlaceholders(); + armArtObserver(); } //////////////////////////////////////////// @@ -928,7 +1045,7 @@ function renderPlayer() { litRender( html` -
+
${hasTrack && track ? (artUrl ? html`` : html`