// Shared reactive app state (Svelte 5 runes module).
import * as api from "./api.js";
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
import {
initPreviewBridge,
bumpMainRevision,
notePlaybackGeneration,
} from "./preview/previewSession.svelte.js";
import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js";
export const app = $state({
connection: "offline", // offline | connecting | online
username: "",
loginError: "",
showLogin: false,
});
export const ui = $state({
view: "home",
albumId: null, // selected album in Library view
playlistId: null, // selected playlist in Playlist view
nowPlaying: true, // right-side Now Playing panel open
sidebarCollapsed: false, // left nav collapsed to an icon rail
fullscreenArt: false, // Spotify-style full-window now-playing view
// WebView2 keeps document.visibilityState="visible" while the native
// window is minimized. This is the authoritative shared activity gate for
// hidden animation, canvas, carousel, and high-frequency position work.
windowActive: true,
});
export function toggleNowPlaying() {
ui.nowPlaying = !ui.nowPlaying;
}
export function toggleSidebar() {
ui.sidebarCollapsed = !ui.sidebarCollapsed;
}
export const libraryStore = $state({
albums: [],
loaded: false,
});
export const playlistStore = $state({
playlists: [],
});
export async function refreshPlaylists() {
try {
playlistStore.playlists = await api.listPlaylists();
} catch {
// backend not available
}
}
const MIB = 1024 * 1024;
function isPending(value) {
return value && typeof value.then === "function";
}
function touchCache(cache, key) {
if (!cache.has(key)) return undefined;
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
function cacheValueChars(value) {
if (typeof value === "string") return value.length;
if (typeof value?.data_url === "string") return value.data_url.length;
if (Number.isFinite(value?.byteLength)) return value.byteLength;
return 0;
}
/** Keep resolved base64 payloads on a byte-like character budget while never
* evicting in-flight promises. Visible
elements retain their own src,
* so dropping a cold cache reference cannot degrade an already-painted image. */
function trimResolvedCache(cache, maxEntries, maxChars) {
let entries = 0;
let chars = 0;
for (const value of cache.values()) {
if (isPending(value)) continue;
entries += 1;
chars += cacheValueChars(value);
}
if (entries <= maxEntries && chars <= maxChars) return;
for (const [key, value] of cache) {
if (isPending(value)) continue;
cache.delete(key);
entries -= 1;
chars -= cacheValueChars(value);
if (entries <= maxEntries && chars <= maxChars) break;
}
}
function setResolvedCache(cache, key, value, maxEntries, maxChars) {
cache.delete(key);
cache.set(key, value);
trimResolvedCache(cache, maxEntries, maxChars);
}
function setBoundedMap(cache, key, value, maxEntries) {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
}
// path -> data URL, bounded so browsing a large library cannot retain every
// base64 cover for the entire WebView lifetime.
const artCache = new Map();
const ART_CACHE_MAX = 128;
const ART_CACHE_MAX_CHARS = 32 * MIB;
export function evictArt(path) {
artCache.delete(path);
}
export async function getArt(path) {
if (!path) return null;
if (artCache.has(path)) return touchCache(artCache, path);
// PlayerBar, NowPlaying, and accent extraction can all request the same
// cover during one track change. Cache the in-flight promise so one native
// read/base64 conversion satisfies every consumer.
const pending = api.albumArt(path).then(
(url) => {
if (artCache.get(path) === pending) {
setResolvedCache(
artCache,
path,
url,
ART_CACHE_MAX,
ART_CACHE_MAX_CHARS,
);
}
return url;
},
() => {
if (artCache.get(path) === pending) {
setResolvedCache(
artCache,
path,
null,
ART_CACHE_MAX,
ART_CACHE_MAX_CHARS,
);
}
return null;
},
);
artCache.set(path, pending);
return pending;
}
// Catalog cover URLs → data URLs, proxied through Rust (no CORS on CDNs).
const catalogArtCache = new Map();
const CATALOG_ART_CACHE_MAX = 160;
const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB;
export async function getCatalogArt(url) {
if (!url) return null;
// Already-inline art (e.g. a cover-derived logo) has nothing to proxy.
if (url.startsWith("data:")) return url;
if (catalogArtCache.has(url)) return touchCache(catalogArtCache, url);
// Cache the promise immediately, not only its result. Multiple components
// often request the same art during one render; one IPC/base64 conversion
// should satisfy every waiter.
const pending = api.catalogArt(url).then(
(dataUrl) => {
if (catalogArtCache.get(url) === pending) {
setResolvedCache(
catalogArtCache,
url,
dataUrl,
CATALOG_ART_CACHE_MAX,
CATALOG_ART_CACHE_MAX_CHARS,
);
}
return dataUrl;
},
() => {
// CDN/provider failures are transient; allow the next visit to retry.
if (catalogArtCache.get(url) === pending) catalogArtCache.delete(url);
return null;
},
);
catalogArtCache.set(url, pending);
return pending;
}
// Remote catalog cover URLs that failed to load — a dead CDN link, a Cover Art
// Archive 404 (MusicBrainz-supplemented cards are given a `front-500` URL
// whether or not CAA actually holds art for that release group), or bytes that
// won't decode. Cards read `artBroken[url]` and fall back to their disc
// placeholder, so a dead provider URL renders the placeholder instead of the
// browser's broken-image glyph. Session-scoped and keyed by URL (a dead URL
// stays dead); bounded so a marathon browse can't retain unbounded strings.
export const artBroken = $state({});
export function markArtBroken(url) {
if (!url || artBroken[url]) return;
const keys = Object.keys(artBroken);
if (keys.length > 2000) for (const k of keys) delete artBroken[k];
artBroken[url] = true;
// Remote art hosts flake transiently (Cover Art Archive redirects into
// archive.org, which routinely times out under load) — one failed load
// must not pin the placeholder for the whole session. Re-probe off-DOM
// and un-mark on success; cards restore reactively.
if (/^https?:/i.test(url) && !artRetriesStarted.has(url)) {
artRetriesStarted.add(url);
retryBrokenArt(url, 0);
}
}
// Cover Art Archive redirects into archive.org, which can be down for minutes
// at a time, so the old two-shot 4s/20s ladder simply exhausted itself inside
// half a minute and pinned the placeholder for the rest of the session. Retry
// further out, and — crucially — RELEASE the url from `artRetriesStarted` when
// the ladder ends, so revisiting the artist later starts a fresh cycle instead
// of being permanently locked out.
const ART_RETRY_DELAYS = [3_000, 10_000, 30_000, 90_000];
const artRetriesStarted = new Set();
function retryBrokenArt(url, attempt) {
if (attempt >= ART_RETRY_DELAYS.length) {
artRetriesStarted.delete(url);
return;
}
setTimeout(() => {
if (!artBroken[url]) {
artRetriesStarted.delete(url);
return;
}
const probe = new Image();
probe.onload = () => {
delete artBroken[url];
artRetriesStarted.delete(url);
};
probe.onerror = () => retryBrokenArt(url, attempt + 1);
probe.src = url;
}, ART_RETRY_DELAYS[attempt]);
}
// Catalog cover upgrades: original cover URL -> a verified higher-resolution
// URL from the no-MusicBrainz backend resolver (Apple / Deezer native max /,
// for MusicBrainz-supplemented cards only, Cover Art Archive). The grid paints
// the instant Deezer cover, then swaps to the upgrade when it resolves. Keyed
// by URL so a shared cover resolves once; bounded for a long browse.
export const coverUpgrades = $state({});
const coverUpgradeInflight = new Map(); // identity + original url -> Promise
const coverUpgradeResults = new Map(); // bounded LRU: request key -> result context
const coverUpgradeContexts = new Map(); // original url -> disk-cache producer
const coverUpgradeMisses = new Map(); // original url -> attempted-at
const coverUpgradeQueue = [];
let coverUpgradeActive = 0;
let coverUpgradeScope = 0;
let coverUpgradeArtistId = null;
const COVER_UPGRADE_CONCURRENCY = 4;
const COVER_UPGRADE_MAX = 400;
const COVER_UPGRADE_MISS_TTL_MS = 2 * 60 * 1000;
function advanceCoverUpgradeScope(artistId) {
coverUpgradeArtistId = artistCacheKey(artistId) || null;
coverUpgradeScope += 1;
// Queued work has not crossed IPC yet, so it is safe to cancel when its
// artist is no longer visible. At most four already-running jobs finish;
// the new artist never sits behind an arbitrarily long stale backlog.
for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) {
const job = coverUpgradeQueue[index];
if (job.sticky || job.scope === coverUpgradeScope) continue;
coverUpgradeQueue.splice(index, 1);
if (coverUpgradeInflight.get(job.key) === job.promise) {
coverUpgradeInflight.delete(job.key);
}
job.resolve(null);
}
}
function activateCoverUpgradeScope(artistId) {
if (sameArtistId(coverUpgradeArtistId, artistId)) return;
advanceCoverUpgradeScope(artistId);
}
/** Cancel artist-grid jobs that have not crossed IPC when that view leaves.
* Sticky currently-playing art and the four already-running calls are kept. */
export function leaveCoverUpgradeScope(artistId) {
if (!sameArtistId(coverUpgradeArtistId, artistId)) return;
advanceCoverUpgradeScope(null);
}
function coverUpgradeRequestKey(artistId, url) {
return `${artistCacheKey(artistId)}\u0000${url}`;
}
function rememberCoverUpgrade(key, url, best, artistId, artist, title) {
const result = { key, url, best, artistId, artist, title };
coverUpgradeResults.delete(key);
coverUpgradeResults.set(key, result);
coverUpgradeContexts.set(url, result);
coverUpgrades[url] = best;
while (coverUpgradeResults.size > COVER_UPGRADE_MAX) {
const oldest = coverUpgradeResults.keys().next().value;
const removed = coverUpgradeResults.get(oldest);
coverUpgradeResults.delete(oldest);
if (coverUpgradeContexts.get(removed?.url)?.key === oldest) {
coverUpgradeContexts.delete(removed.url);
if (coverUpgrades[removed.url] === removed.best) delete coverUpgrades[removed.url];
}
}
}
function rememberCoverUpgradeMiss(key) {
coverUpgradeMisses.delete(key);
coverUpgradeMisses.set(key, Date.now());
while (coverUpgradeMisses.size > COVER_UPGRADE_MAX) {
coverUpgradeMisses.delete(coverUpgradeMisses.keys().next().value);
}
}
function pumpCoverUpgradeQueue() {
while (coverUpgradeActive < COVER_UPGRADE_CONCURRENCY && coverUpgradeQueue.length) {
// Current-page viewport work wins over retained player/album jobs.
const currentIndex = coverUpgradeQueue.findIndex(
(candidate) => !candidate.sticky && candidate.scope === coverUpgradeScope,
);
const job = currentIndex > 0
? coverUpgradeQueue.splice(currentIndex, 1)[0]
: coverUpgradeQueue.shift();
coverUpgradeActive += 1;
api.catalogCover(job.artistId, job.artist, job.title, job.url)
.then((best) => {
if (best && best !== job.url) {
coverUpgradeMisses.delete(job.key);
rememberCoverUpgrade(
job.key,
job.url,
best,
job.artistId,
job.artist,
job.title,
);
} else {
rememberCoverUpgradeMiss(job.key);
}
job.resolve(best ?? null);
})
.catch(() => {
rememberCoverUpgradeMiss(job.key);
job.resolve(null);
})
.finally(() => {
if (coverUpgradeInflight.get(job.key) === job.promise) {
coverUpgradeInflight.delete(job.key);
}
coverUpgradeActive -= 1;
pumpCoverUpgradeQueue();
});
}
}
function keepOnlyLatestStickyCoverJob(url) {
for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) {
const job = coverUpgradeQueue[index];
if (!job.sticky || job.url === url) continue;
coverUpgradeQueue.splice(index, 1);
if (coverUpgradeInflight.get(job.key) === job.promise) {
coverUpgradeInflight.delete(job.key);
}
job.resolve(null);
}
}
function scheduleCoverUpgrade(artistId, artist, title, url, sticky = false) {
const key = coverUpgradeRequestKey(artistId, url);
// Player changes supersede older queued player-art polish. Running calls
// finish safely, but rapid track skipping cannot accumulate an immortal
// sticky backlog across every artist scope.
if (sticky) keepOnlyLatestStickyCoverJob(url);
if (coverUpgradeResults.has(key)) {
const result = touchCache(coverUpgradeResults, key);
coverUpgradeContexts.set(url, result);
if (coverUpgrades[url] !== result.best) coverUpgrades[url] = result.best;
return Promise.resolve(result.best);
}
const published = coverUpgradeContexts.get(url);
if (published?.key === key && coverUpgrades[url]) {
return Promise.resolve(coverUpgrades[url]);
}
const attemptedAt = coverUpgradeMisses.get(key);
if (attemptedAt && Date.now() - attemptedAt < COVER_UPGRADE_MISS_TTL_MS) {
return Promise.resolve(null);
}
if (coverUpgradeInflight.has(key)) {
const existing = coverUpgradeInflight.get(key);
if (sticky) {
const index = coverUpgradeQueue.findIndex((job) => job.promise === existing);
if (index >= 0) {
const [job] = coverUpgradeQueue.splice(index, 1);
job.sticky = true;
coverUpgradeQueue.unshift(job);
}
}
return existing;
}
let complete;
const promise = new Promise((resolve) => { complete = resolve; });
const job = {
key,
artistId: artistId ?? null,
artist: artist ?? "",
title: title ?? "",
url,
resolve: complete,
promise,
sticky,
scope: coverUpgradeScope,
};
// Register before enqueueing: staged overview/full-page calls can no longer
// create separate worker pools containing the same not-yet-started URL.
coverUpgradeInflight.set(key, promise);
if (sticky) coverUpgradeQueue.unshift(job);
else coverUpgradeQueue.push(job);
pumpCoverUpgradeQueue();
return promise;
}
/** Resolve higher-resolution covers for catalog albums, in parallel with a
* concurrency cap. The backend does NO MusicBrainz lookup, so this fans out
* freely. Idempotent — already-resolved or in-flight covers are skipped. */
export async function resolveCovers(
albums,
artist,
{ sticky = false, artistId = null } = {},
) {
const requests = [];
const seen = new Set();
for (const a of albums ?? []) {
const url = a?.cover;
if (!url || seen.has(url)) continue;
seen.add(url);
requests.push(scheduleCoverUpgrade(artistId, artist, a.title ?? "", url, sticky));
}
await Promise.all(requests);
}
/** A catalog cover img failed to load. If it was an upgraded URL, revert to the
* instant Deezer cover; if the original itself failed, show the placeholder. */
export function coverLoadError(
originalCover,
failedUrl = null,
artist = "",
title = "",
artistId = null,
) {
if (!originalCover) return null;
if (coverUpgrades[originalCover] && coverUpgrades[originalCover] !== originalCover) {
const producer = coverUpgradeContexts.get(originalCover);
delete coverUpgrades[originalCover];
// Do not let the bounded LRU immediately republish the URL that just
// failed in an
. A short miss window keeps the working original
// in place while still allowing transient CDN failures to retry later.
const requestKey = producer?.key ?? coverUpgradeRequestKey(artistId, originalCover);
coverUpgradeResults.delete(requestKey);
if (coverUpgradeContexts.get(originalCover)?.key === requestKey) {
coverUpgradeContexts.delete(originalCover);
}
rememberCoverUpgradeMiss(requestKey);
const cacheArtistId = producer?.artistId ?? artistId;
const cacheArtist = producer?.artist ?? artist;
const cacheTitle = producer?.title ?? title;
if (cacheArtist && cacheTitle) {
void api
.invalidateCatalogCover(
cacheArtistId,
cacheArtist,
cacheTitle,
originalCover,
)
.catch(() => {});
}
return originalCover;
} else {
// A card intentionally requests a 500px CDN derivative. If only that
// resized route failed, retry the known-good canonical source before
// declaring the album itself broken.
if (failedUrl) {
try {
if (new URL(failedUrl).href !== new URL(originalCover).href) {
return originalCover;
}
} catch {
if (failedUrl !== originalCover) return originalCover;
}
}
markArtBroken(originalCover);
return null;
}
}
/** Cover URL to display for a currently-playing catalog item. Kicks off an
* idempotent high-res upgrade for its album and returns the best URL to show
* now — the verified upgrade once resolved, else the instant Deezer cover. The
* player surfaces proxy the returned URL through getCatalogArt; because this
* reads the reactive coverUpgrades map, their effect re-runs and re-proxies at
* full resolution the moment the upgrade lands. */
export function playerArtSource(item) {
const url = item?.artUrl;
if (!url) return null;
if (item.album) {
resolveCovers(
[{ cover: url, title: item.album }],
item.artist ?? "",
{ sticky: true },
);
}
return coverUpgrades[url] ?? url;
}
// Artist heroes have their own proxy because provider fallbacks may be finished
// as baked 4K JPEGs. Never share this cache with covers/logos.
const catalogHeroPreviewCache = new Map();
const catalogHeroArtCache = new Map();
const catalogHeroGpuCache = new Map();
const CATALOG_HERO_PREVIEW_CACHE_MAX = 16;
const CATALOG_HERO_ART_CACHE_MAX = 10;
const CATALOG_HERO_GPU_CACHE_MAX = 2;
const CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS = 12 * MIB;
const CATALOG_HERO_ART_CACHE_MAX_CHARS = 32 * MIB;
const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 32 * MIB;
const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 24 * MIB;
let catalogHeroArtEpoch = 0;
let heroWarmMode = null;
let heroWarmModeRequest = null;
let heroWarmModeEpoch = 0;
function getHeroWarmMode() {
if (heroWarmMode) return Promise.resolve(heroWarmMode);
if (heroWarmModeRequest) return heroWarmModeRequest;
const epoch = heroWarmModeEpoch;
const pending = api.getSettings().then(
(settings) => settings?.artist_hero_upscaler ?? "live_gpu",
() => "live_gpu",
).then((mode) => {
if (epoch === heroWarmModeEpoch) heroWarmMode = mode;
return mode;
}).finally(() => {
if (heroWarmModeRequest === pending) heroWarmModeRequest = null;
});
heroWarmModeRequest = pending;
return pending;
}
function setBoundedHeroCache(cache, key, value, maxEntries, maxChars) {
// Refresh insertion order so frequently revisited heroes survive eviction.
cache.delete(key);
cache.set(key, value);
trimResolvedCache(cache, maxEntries, maxChars);
}
export function clearCatalogHeroArtCache() {
catalogHeroArtEpoch += 1;
// Settings calls this after changing upscalers. Refresh the warm-path hint
// so the next artist prefetch targets the newly selected pipeline.
heroWarmModeEpoch += 1;
heroWarmMode = null;
heroWarmModeRequest = null;
catalogHeroPreviewCache.clear();
catalogHeroArtCache.clear();
catalogHeroGpuCache.clear();
}
/** Return the fast hero raster together with the immutable content-aware crop
* plan that produced it. Cache the whole pair so the deferred 4K finisher can
* apply the exact same source window without running detection a second time. */
export async function getCatalogHeroPreview(url) {
if (!url) return null;
if (catalogHeroPreviewCache.has(url)) {
return touchCache(catalogHeroPreviewCache, url);
}
const epoch = catalogHeroArtEpoch;
const pending = api.catalogHeroPreview(url).then(
(result) => {
const preview = result?.data_url ? result : null;
if (
epoch === catalogHeroArtEpoch
&& catalogHeroPreviewCache.get(url) === pending
) {
if (preview) {
setBoundedHeroCache(
catalogHeroPreviewCache,
url,
preview,
CATALOG_HERO_PREVIEW_CACHE_MAX,
CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS,
);
}
else catalogHeroPreviewCache.delete(url);
}
return epoch === catalogHeroArtEpoch ? preview : null;
},
() => {
if (catalogHeroPreviewCache.get(url) === pending) {
catalogHeroPreviewCache.delete(url);
}
return null;
},
);
setBoundedHeroCache(
catalogHeroPreviewCache,
url,
pending,
CATALOG_HERO_PREVIEW_CACHE_MAX,
CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS,
);
return pending;
}
/** Share the expensive source decode/crop/raw-RGBA IPC across provisional→final
* promotion and rapid carousel/remount races. Large sources remain single-flight
* while pending but are dropped after use so two 4K payloads cannot pin RAM. */
export async function getCatalogHeroGpuSource(url, plan = null) {
if (!url) return null;
if (catalogHeroGpuCache.has(url)) return touchCache(catalogHeroGpuCache, url);
const epoch = catalogHeroArtEpoch;
const pending = api.catalogHeroGpuSource(url, plan).then(
(payload) => {
if (epoch !== catalogHeroArtEpoch) return null;
if (catalogHeroGpuCache.get(url) === pending) {
if (payload?.byteLength <= CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES) {
setBoundedHeroCache(
catalogHeroGpuCache,
url,
payload,
CATALOG_HERO_GPU_CACHE_MAX,
CATALOG_HERO_GPU_CACHE_MAX_BYTES,
);
} else {
catalogHeroGpuCache.delete(url);
}
}
return payload;
},
() => {
if (catalogHeroGpuCache.get(url) === pending) catalogHeroGpuCache.delete(url);
return null;
},
);
setBoundedHeroCache(
catalogHeroGpuCache,
url,
pending,
CATALOG_HERO_GPU_CACHE_MAX,
CATALOG_HERO_GPU_CACHE_MAX_BYTES,
);
return pending;
}
/** Return only an already-finished persistent hero, without starting an
* upscale on a cache miss. This lets repeat visits race disk against the HD
* preview while keeping first-ever processing behind the first painted frame. */
export async function getCachedCatalogHeroArt(url) {
if (!url) return null;
const existing = catalogHeroArtCache.get(url);
// A full upscale already in progress is not a cache hit. Probe disk and
// continue scanning other candidates instead of waiting behind that job.
if (typeof existing === "string") {
touchCache(catalogHeroArtCache, url);
return existing;
}
const epoch = catalogHeroArtEpoch;
try {
const dataUrl = await api.catalogCachedHeroArt(url);
if (epoch !== catalogHeroArtEpoch) return null;
if (dataUrl) {
setBoundedHeroCache(
catalogHeroArtCache,
url,
dataUrl,
CATALOG_HERO_ART_CACHE_MAX,
CATALOG_HERO_ART_CACHE_MAX_CHARS,
);
}
return dataUrl;
} catch {
return null;
}
}
export async function getCatalogHeroArt(url, plan = null) {
if (!url) return null;
if (catalogHeroArtCache.has(url)) return touchCache(catalogHeroArtCache, url);
const pending = api.catalogHeroArt(url, plan).then(
(result) => {
const dataUrl = result?.data_url ?? result;
const cacheable = result?.cacheable !== false;
// A manual-art refresh may clear the map while this request is in
// flight. Do not let an obsolete completion repopulate it.
if (catalogHeroArtCache.get(url) === pending) {
if (cacheable) {
setBoundedHeroCache(
catalogHeroArtCache,
url,
dataUrl,
CATALOG_HERO_ART_CACHE_MAX,
CATALOG_HERO_ART_CACHE_MAX_CHARS,
);
}
else catalogHeroArtCache.delete(url);
}
return dataUrl;
},
() => {
if (catalogHeroArtCache.get(url) === pending) {
catalogHeroArtCache.delete(url);
}
return null;
},
);
setBoundedHeroCache(
catalogHeroArtCache,
url,
pending,
CATALOG_HERO_ART_CACHE_MAX,
CATALOG_HERO_ART_CACHE_MAX_CHARS,
);
return pending;
}
export async function refreshLibrary() {
try {
// Artist/art refreshes replace files at the same cached path. Drop
// data-URL entries so the webview fetches the newly resized image.
artCache.clear();
libraryStore.albums = await api.libraryAlbums();
libraryStore.loaded = true;
window.__libErr = null;
} catch (e) {
// not connected to backend (browser preview) — leave empty
window.__libErr = String(e);
}
}
// ---- catalog browser (Deezer-backed streaming-style search) ----
export const catalog = $state({
query: "",
loading: false,
results: null, // { artists, albums, tracks }
artistLoading: false,
artistStageError: "",
artistPage: null, // { artist, albums }
// Bumped once per openCatalogArtist call, INCLUDING a re-open of the artist
// already on screen. The view opens itself at the top off this rather than
// off the artist's id: the id cannot tell a re-open from no navigation at
// all, and it cannot tell the cold path's stage-by-stage replacement of
// artistPage (same id, new object, several times a second) from a real one
// — keying on that would drag a reader back to the top mid-read.
artistOpenSeq: 0,
albumLoading: false,
albumPage: null, // { album, tracks }
pendingTrackId: null, // a track to scroll to/flash on the album page
error: "",
});
let artistPageRequest = 0;
let artistWarmGeneration = 0;
let catalogSearchRequest = 0;
let albumPageRequest = 0;
let albumResolveRequest = 0;
const ARTIST_REQUEST_CACHE_MAX = 24;
const ARTIST_IDENTITY_CACHE_MAX = 128;
const ARTIST_LOGO_CACHE_MAX = 64;
const artistIdCache = new Map(); // normalized name → Deezer id (or null)
const artistSeedCache = new Map(); // normalized name → search-result seed
const artistSeedById = new Map(); // Deezer id → latest known artist seed
const catalogArtistOverviewCache = new Map();
const catalogArtistHeroSeedCache = new Map();
const catalogArtistArtDataCache = new Map();
const catalogArtistLogoDataCache = new Map();
const catalogArtistPageCache = new Map();
// One canonical representation for all frontend artist cache keys. Route ids
// can arrive as strings while Tauri payload ids are numbers; treating those as
// separate keys wastes work and makes otherwise-correct late-result guards fail.
const artistCacheKey = (id) => (id == null ? "" : String(id).trim());
const albumCacheKey = (id) => (id == null ? "" : String(id).trim());
const sameArtistId = (left, right) => {
const leftKey = artistCacheKey(left);
return !!leftKey && leftKey === artistCacheKey(right);
};
const sameAlbumId = (left, right) => {
const leftKey = albumCacheKey(left);
return !!leftKey && leftKey === albumCacheKey(right);
};
const artistIdentityKey = (value) => {
const text = typeof value === "string" ? value : value?.name;
return (text ?? "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
};
/** Accept an exact artist credit or an explicit collaboration credit containing
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
* "Crazy Penis"). Missing credit metadata is retained because there is no
* identity signal to evaluate. */
function recordBelongsToArtist(record, artistName) {
const credited = typeof record?.artist === "string"
? record.artist
: record?.artist?.name;
if (!credited) return true;
const expected = artistIdentityKey(artistName);
if (!expected) return true;
if (artistIdentityKey(credited) === expected) return true;
return credited
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
.some((part) => artistIdentityKey(part) === expected);
}
function artistScopedRecords(records, artistName) {
return Array.isArray(records)
? records.filter((record) => recordBelongsToArtist(record, artistName))
: [];
}
/** Sanitize every staged/full artist payload before it enters a shared cache.
* Backend endpoints already anchor on id; this cheap name-credit boundary is a
* second line of defense for additive provider records such as Apple/MB data. */
function artistScopedPayload(payload) {
const artistName = payload?.artist?.name ?? "";
return {
...payload,
...(Object.hasOwn(payload ?? {}, "albums")
? { albums: artistScopedRecords(payload.albums, artistName) }
: {}),
...(Object.hasOwn(payload ?? {}, "popular_tracks")
? { popular_tracks: artistScopedRecords(payload.popular_tracks, artistName) }
: {}),
};
}
function checkedArtistPayload(artistId, payload, stage) {
if (!sameArtistId(payload?.artist?.id, artistId)) {
throw new Error(`${stage} returned a mismatched artist identity`);
}
return artistScopedPayload(payload);
}
function rememberArtistSeed(seed) {
if (!seed?.id) return null;
const idKey = artistCacheKey(seed.id);
const prior = artistSeedById.get(idKey);
const remembered = {
id: seed.id,
name: seed.name ?? prior?.name ?? "",
picture: seed.picture ?? prior?.picture ?? null,
fans: seed.fans ?? prior?.fans ?? null,
album_count:
seed.album_count ?? seed.albumCount ?? prior?.album_count ?? null,
};
setBoundedMap(
artistSeedById,
idKey,
remembered,
ARTIST_IDENTITY_CACHE_MAX,
);
const norm = remembered.name.trim().toLowerCase();
if (norm) {
const existing = artistSeedCache.get(norm);
const sameIdentity = sameArtistId(existing?.id, remembered.id);
const moreCanonical = (remembered.fans ?? -1) > (existing?.fans ?? -1);
// Deezer can return several exact-name matches. Preserve the most-
// followed canonical seed instead of letting the last obscure namesake
// in a search response poison all future name-only navigation.
if (!existing || sameIdentity || moreCanonical) {
setBoundedMap(
artistSeedCache,
norm,
remembered,
ARTIST_IDENTITY_CACHE_MAX,
);
setBoundedMap(
artistIdCache,
norm,
remembered.id,
ARTIST_IDENTITY_CACHE_MAX,
);
}
}
return remembered;
}
function trimArtistRequestCache(cache) {
let resolved = 0;
for (const value of cache.values()) {
if (!isPending(value)) resolved += 1;
}
for (const [key, value] of cache) {
if (resolved <= ARTIST_REQUEST_CACHE_MAX) break;
if (isPending(value)) continue;
cache.delete(key);
resolved -= 1;
}
}
function cachedArtistRequest(cache, artistId, load) {
const key = artistCacheKey(artistId);
if (!key) return Promise.reject(new Error("artist id is required"));
if (cache.has(key)) return Promise.resolve(touchCache(cache, key));
const pending = load().then(
(result) => {
if (cache.get(key) === pending) {
cache.delete(key);
cache.set(key, result);
trimArtistRequestCache(cache);
}
return result;
},
(error) => {
if (cache.get(key) === pending) cache.delete(key);
throw error;
},
);
cache.set(key, pending);
trimArtistRequestCache(cache);
return pending;
}
function resolvedArtistCacheValue(cache, artistId) {
const value = cache.get(artistCacheKey(artistId));
return value && typeof value.then !== "function" ? value : null;
}
export const getCatalogArtistOverview = (artistId) =>
cachedArtistRequest(catalogArtistOverviewCache, artistId, () =>
api.catalogArtistOverview(artistId).then((payload) =>
checkedArtistPayload(artistId, payload, "artist overview")),
);
export const getCatalogArtistHeroSeed = (artistId) =>
cachedArtistRequest(catalogArtistHeroSeedCache, artistId, () =>
api.catalogArtistHeroSeed(artistId),
).then((seed) => {
// The backend applies a short negative TTL. Do not turn a transient
// no-seed result into a frontend-session-long miss.
if (!seed) catalogArtistHeroSeedCache.delete(artistCacheKey(artistId));
return seed;
});
export const getCatalogArtistIdentityArt = (artistId) =>
cachedArtistRequest(catalogArtistArtDataCache, artistId, () =>
api.catalogArtistArt(artistId).then((payload) =>
checkedArtistPayload(artistId, payload, "artist art")),
).then((art) => {
// Empty provider/Bing results have a short backend TTL and may recover
// on a later visit. Keep the shared promise for the whole in-flight
// request, but do not turn a valid no-art payload into a session-long
// frontend miss. Positive banners, custom art and logos stay cached.
const hasArt = Boolean(
art?.banner_custom
|| art?.banner
|| art?.banners?.length
|| art?.logos?.length
);
if (!hasArt) catalogArtistArtDataCache.delete(artistCacheKey(artistId));
return art;
});
export const getCatalogArtistProviderLogos = (artistId) =>
cachedArtistRequest(catalogArtistLogoDataCache, artistId, () =>
api.catalogArtistLogos(artistId).then((logos) => {
if (!Array.isArray(logos) || logos.some((logo) => typeof logo !== "string")) {
throw new Error("artist logos returned an invalid response");
}
rememberProviderLogoStatus(artistId, logos.length > 0);
if (!logos.length) {
// Backend provider-art misses intentionally have a short TTL.
// Keep this page's settled negative decision, but do not pin it
// in the frontend request cache for the rest of the session.
catalogArtistLogoDataCache.delete(artistCacheKey(artistId));
}
return logos;
}),
);
export const getCatalogArtistPage = (artistId) =>
cachedArtistRequest(catalogArtistPageCache, artistId, () =>
api.catalogArtist(artistId).then((payload) =>
checkedArtistPayload(artistId, payload, "artist page")),
);
export function invalidateCatalogArtist(artistId) {
const key = artistCacheKey(artistId);
catalogArtistOverviewCache.delete(key);
catalogArtistHeroSeedCache.delete(key);
catalogArtistArtDataCache.delete(key);
catalogArtistLogoDataCache.delete(key);
catalogArtistPageCache.delete(key);
providerLogoStatusById.delete(key);
for (const logoKey of coverLogoByInput.keys()) {
if (logoKey.startsWith(`${key}\u0000`)) coverLogoByInput.delete(logoKey);
}
}
// ---- Stale-while-revalidate page replay -----------------------------------
// A revisit after an app restart used to start from the loading shell even
// though the previous session had the artist's FINAL merged page. The last
// merged page per artist is persisted (localStorage, MRU-capped) and painted
// immediately as a stale stand-in — paint-only: every stage of the normal
// cold flow still runs and replaces it wholesale, so this can never repeat
// the old "cached page skipped the MB pass" bug. Replayed pages keep
// `art_preview: true` (the full page still lands) and set `replayed: true`
// (the overview stage must not overwrite final classified albums with its
// raw first-50).
// v2: pages saved before guest appearances were filtered are stamped
// `identity_merged`, so they would skip the pass that drops them and keep
// rendering another artist's records. Cheap to discard — the replay only buys
// an instant first paint, and a miss is one ordinary load.
const PAGE_REPLAY_RECIPE = "replay-v2";
const PAGE_REPLAY_LIMIT = 16;
// Matches the backend's provider disk-cache TTL on purpose: a replay must
// never outlive the MusicBrainz warmth it was saved with, or the identity
// re-merge that replaces it stops being instant.
const PAGE_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
const PAGE_REPLAY_INDEX = "ss-page-replay-index";
const pageReplayKey = (key) => `ss-page-replay:${key}`;
function loadPageReplay(artistId) {
try {
const key = artistCacheKey(artistId);
if (!key) return null;
const raw = localStorage.getItem(pageReplayKey(key));
if (!raw) return null;
const entry = JSON.parse(raw);
if (entry?.recipe !== PAGE_REPLAY_RECIPE) return null;
if (!entry.at || Date.now() - entry.at > PAGE_REPLAY_TTL_MS) return null;
const page = entry.page;
if (!sameArtistId(page?.artist?.id, artistId)) return null;
if (!Array.isArray(page?.albums) || !page.albums.length) return null;
return page;
} catch {
return null;
}
}
function savePageReplay(artistId, page) {
try {
const key = artistCacheKey(artistId);
if (!key || !page?.albums?.length) return;
// Only durable content fields. Art (banners/logos/cover_logo) is
// deliberately excluded: those pipelines have their own caches and
// settled/override semantics that a replay must not preempt.
const slim = {
artist: page.artist,
popular_tracks: page.popular_tracks ?? [],
albums: page.albums,
top_genre: page.top_genre ?? null,
socials: page.socials ?? [],
typeHints: page.typeHints ?? [],
identity_merged: true,
};
localStorage.setItem(
pageReplayKey(key),
JSON.stringify({ recipe: PAGE_REPLAY_RECIPE, at: Date.now(), page: slim }),
);
const index = JSON.parse(localStorage.getItem(PAGE_REPLAY_INDEX) ?? "[]");
const next = [key, ...index.filter((k) => k !== key)];
for (const evicted of next.slice(PAGE_REPLAY_LIMIT)) {
localStorage.removeItem(pageReplayKey(evicted));
}
localStorage.setItem(PAGE_REPLAY_INDEX, JSON.stringify(next.slice(0, PAGE_REPLAY_LIMIT)));
} catch {
// Storage full or unavailable — the replay is purely optional.
}
}
/** Warm only the two visible stages while a user reads search results. The
* expensive exhaustive page remains click-driven. */
export function cancelCatalogArtistWarmup() {
artistWarmGeneration += 1;
}
export function warmCatalogArtist(seed) {
const remembered = rememberArtistSeed(seed);
if (!remembered) return;
const warmGeneration = ++artistWarmGeneration;
const heroModeReady = getHeroWarmMode();
void getCatalogArtistOverview(remembered.id).catch(() => {});
void getCatalogArtistHeroSeed(remembered.id)
.then(async (seed) => {
if (warmGeneration !== artistWarmGeneration) return;
if (seed?.banner && !seed.banner.startsWith("data:")) {
// Warm exactly the asset the selected renderer consumes. Live
// GPU never paints the legacy HD data URL, so preparing it would
// waste an extra decode and up to 12 MiB of WebView cache.
const mode = await heroModeReady;
if (warmGeneration !== artistWarmGeneration) return;
if (mode === "live_gpu") {
void getCatalogHeroGpuSource(seed.banner, null).catch(() => {});
} else {
void getCatalogHeroPreview(seed.banner).catch(() => {});
}
}
})
.catch(() => {});
void getCatalogArtistProviderLogos(remembered.id)
.then((logos) => {
const primaryLogo = logos?.[0];
if (primaryLogo) void getCatalogArt(primaryLogo);
})
.catch(() => {});
// Also warm the MusicBrainz identity chain — the slowest piece of a cold
// artist page (3-5 requests behind MB's mandatory 1 req/s gate). Gated
// three ways so live-search churn can never flood MusicBrainz: only after
// 600ms of the artist staying the top hit (a newer warm generation
// supersedes the timer), only once per artist per session, and only for
// this single seed — never a result list.
const identityKey = artistCacheKey(remembered.id);
if (identityKey && remembered.name && !identityWarmupDone.has(identityKey)) {
setTimeout(() => {
if (warmGeneration !== artistWarmGeneration) return;
if (identityWarmupDone.has(identityKey)) return;
identityWarmupDone.add(identityKey);
void api.catalogArtistIdentity(remembered.id, remembered.name).catch(() => {});
}, 600);
}
}
const identityWarmupDone = new Set();
/** Run a catalog search and show the results view. */
export async function catalogGo(query) {
cancelCatalogArtistWarmup();
const q = query.trim();
if (!q) return;
const request = ++catalogSearchRequest;
ui.view = "catalog";
ui.albumId = null;
catalog.query = q;
catalog.results = null;
catalog.loading = true;
catalog.error = "";
try {
const results = await api.catalogSearch(q);
if (request !== catalogSearchRequest) return;
catalog.results = results;
for (const artist of results?.artists ?? []) rememberArtistSeed(artist);
// Search results are normally read for a moment before the first artist
// is opened. Spend that otherwise-idle time warming its identity/art
// lookup; the keyed backend single-flight shares this exact request if
// the user clicks immediately.
const likelyArtistId = results?.artists?.[0]?.id;
if (likelyArtistId != null) {
warmCatalogArtist(results.artists[0]);
}
} catch (e) {
if (request === catalogSearchRequest) catalog.error = String(e);
} finally {
if (request === catalogSearchRequest) catalog.loading = false;
}
}
/** Open an artist as a four-stage page: synchronous identity shell, fast
* Deezer overview, independent provider artwork, then exhaustive refinement.
* Every ID gets a shell; known search/discovery seeds add the name + blurred
* portrait without waiting for IPC or the network. */
// Provider presence is an identity-scoped, monotonic decision. Once any exact
// provider pass finds a curated logo, a later empty/error result may not expose
// a cover-derived fallback in front of it.
const providerLogoStatusById = new Map();
// Cover-derived results are keyed by the exact identity-filtered cover input,
// not only by artist id. This prevents a negative or wordmark generated from an
// older/polluted discography from being reused after the candidate set changes.
// Values are either an in-flight Promise, a finalized data URL, or null.
const coverLogoByInput = new Map();
const ARTIST_LOGO_CACHE_MAX_CHARS = 12 * MIB;
function rememberProviderLogoStatus(artistId, hasProviderLogo) {
const key = artistCacheKey(artistId);
if (!key) return;
const prior = providerLogoStatusById.get(key);
setBoundedMap(
providerLogoStatusById,
key,
prior === true || hasProviderLogo === true,
ARTIST_LOGO_CACHE_MAX,
);
}
/** Apply a fast, definitive provider-logo decision. With no provider logo, an
* exact-input cached result or generation starts once the hardened final cover
* list is supplied. */
async function resolveCoverLogoAvailability(artistId, page, providerLogosReady, active) {
const logos = await providerLogosReady;
const statusKey = artistCacheKey(artistId);
if (!Array.isArray(logos)) {
if (
providerLogoStatusById.get(statusKey) === false
&& page
&& active()
) {
await fetchCoverLogo(artistId, page, active);
return { provider: false };
}
return { provider: null };
}
rememberProviderLogoStatus(artistId, logos.length > 0);
if (!active()) return { provider: logos.length > 0 };
const current = catalog.artistPage;
if (logos.length) {
if (sameArtistId(current?.artist?.id, artistId)) {
catalog.artistPage = {
...current,
logos: current.logos?.length ? current.logos : logos,
provider_logos_settled: true,
cover_logo: null,
cover_logo_pending: false,
};
}
return { provider: true };
}
if (sameArtistId(current?.artist?.id, artistId) && !current.provider_logos_settled) {
catalog.artistPage = { ...current, provider_logos_settled: true };
}
// Do not use the old artist-id-only persisted restore here. Its candidate
// fingerprint is unavailable, so it could have been generated from the
// polluted discography we are hardening against. The exact-input request
// below still hits the backend's versioned memory/disk cache on revisits.
if (page) await fetchCoverLogo(artistId, { ...page, logos: [] }, active);
return { provider: false };
}
function mergeCatalogArtistArt(artistId, art, active) {
if (!sameArtistId(art?.artist?.id, artistId) || !active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
const finalBanners = art.banners ?? [];
const artLogos = Array.isArray(art.logos) ? art.logos : [];
const finalLogos = current.logos?.length ? current.logos : artLogos;
const mergedArtist = {
...current.artist,
...art.artist,
// The completed identity-filtered page owns this count. A late art
// profile may still carry Deezer's broader/fused total; use it only
// while the shell has no count at all.
album_count: current.artist?.album_count ?? art.artist?.album_count ?? null,
};
rememberArtistSeed(mergedArtist);
rememberProviderLogoStatus(artistId, finalLogos.length > 0);
catalog.artistPage = {
...current,
artist: mergedArtist,
banner: art.banner ?? null,
banners: finalBanners,
banner_custom: art.banner_custom ?? false,
logos: finalLogos,
provider_logos_settled: true,
cover_logo: finalLogos.length ? null : current.cover_logo ?? null,
cover_logo_pending: finalLogos.length ? false : current.cover_logo_pending ?? false,
hero_seed_banner: finalBanners.length ? null : current.hero_seed_banner,
loading_shell: false,
placeholder_picture:
current.placeholder_picture ?? art.artist?.picture ?? null,
};
}
/** Fire the deferred cover-logo extraction for an artist with no provider logo,
* merging the result into its own page field. Backend-cached, so ~instant on a
* revisit. Called from BOTH the cold-load and warm-cache paths of
* openCatalogArtist — the warm path early-returns and would otherwise skip it. */
/** Lower ranks first. Studio-album covers print the bare band name; singles,
* EPs and anything with a remix/live/mix in the title print tracklists and
* credits — poison for a wordmark. So OCR the clean covers first. */
function albumLogoRank(a) {
const type = a.record_type ?? "";
const typeRank = type === "album" ? 0 : type === "ep" ? 2 : 3;
const noisy = /\b(remix|remixes|mix|live|edit|dub|version|bonus|instrumental)\b/i.test(
a.title ?? "",
)
? 1
: 0;
return typeRank + noisy;
}
// Must not undercut the backend's own cap (COVER_LOGO_MAX_COVERS): whatever is
// trimmed here the extractor can never see. A prolific artist's wordmark is
// frequently on a low-ranked remix single — all seven of Poolside's readable
// covers sat past position 14 — and the backend stops early once it has enough
// hits, so a longer list costs nothing for artists whose top covers work.
function coverLogoCandidates(page) {
return [...(page.albums ?? [])]
.sort((a, b) => albumLogoRank(a) - albumLogoRank(b))
.map((a) => ({ title: a.title ?? "", url: a.cover }))
.filter((c) => c.url)
.slice(0, 40);
}
function coverLogoInputKey(artistId, page, covers) {
const idKey = artistCacheKey(artistId);
const artistKey = artistIdentityKey(page.artist?.name);
const candidates = covers
.map(({ title, url }) => `${title}\u0001${url}`)
.join("\u0002");
return `${idKey}\u0000${artistKey}\u0000${candidates}`;
}
function readCoverLogoForPage(artistId, page) {
if (providerLogoStatusById.get(artistCacheKey(artistId)) !== false) return null;
const covers = coverLogoCandidates(page);
if (!covers.length) return null;
const value = touchCache(coverLogoByInput, coverLogoInputKey(artistId, page, covers));
return typeof value === "string" ? value : null;
}
function coverLogoRequest(artistId, page, covers) {
const inputKey = coverLogoInputKey(artistId, page, covers);
if (coverLogoByInput.has(inputKey)) {
return Promise.resolve(touchCache(coverLogoByInput, inputKey));
}
const pending = api
.catalogCoverLogo(artistId, page.artist?.name ?? "", covers)
.then(
(dataUrl) => {
if (dataUrl != null && typeof dataUrl !== "string") {
throw new Error("cover logo returned an invalid response");
}
const result = dataUrl ?? null;
if (coverLogoByInput.get(inputKey) === pending) {
setResolvedCache(
coverLogoByInput,
inputKey,
result,
ARTIST_LOGO_CACHE_MAX,
ARTIST_LOGO_CACHE_MAX_CHARS,
);
}
return result;
},
(error) => {
if (coverLogoByInput.get(inputKey) === pending) {
coverLogoByInput.delete(inputKey);
}
throw error;
},
);
coverLogoByInput.set(inputKey, pending);
trimResolvedCache(
coverLogoByInput,
ARTIST_LOGO_CACHE_MAX,
ARTIST_LOGO_CACHE_MAX_CHARS,
);
return pending;
}
async function fetchCoverLogo(artistId, page, active) {
const statusKey = artistCacheKey(artistId);
if (
!sameArtistId(page?.artist?.id, artistId)
|| page.logos?.length
|| providerLogoStatusById.get(statusKey) !== false
|| !active()
) {
return null;
}
const covers = coverLogoCandidates(page);
if (!covers.length) return null;
const current = catalog.artistPage;
if (
sameArtistId(current?.artist?.id, artistId)
&& !current.cover_logo_pending
&& !readCoverLogoForPage(artistId, page)
) {
catalog.artistPage = { ...current, cover_logo_pending: true };
}
try {
const dataUrl = await coverLogoRequest(artistId, page, covers);
if (!dataUrl || !active()) return dataUrl;
const latest = catalog.artistPage;
if (
!sameArtistId(latest?.artist?.id, artistId)
|| latest.logos?.length
|| providerLogoStatusById.get(statusKey) !== false
) {
return dataUrl;
}
// Its OWN field, never `logos`: the provider-art pass sets `logos`
// (often to []) and would otherwise clobber it. The view prefers
// provider `logos` and falls back to this.
catalog.artistPage = { ...latest, cover_logo: dataUrl };
return dataUrl;
} catch {
return null;
} finally {
const latest = catalog.artistPage;
if (
active()
&& sameArtistId(latest?.artist?.id, artistId)
&& latest.cover_logo_pending
) {
catalog.artistPage = { ...latest, cover_logo_pending: false };
}
}
}
/** MusicBrainz "last pass" for an open artist page. MB is rate-limited to
* 1 req/s by policy, which used to cost the release grid ~2-3s; Apple's
* unthrottled artist lookup now supplies the release supplement, so MB runs
* AFTER the page is on screen and merges in whenever it lands. Deliberately
* additive-only: it contributes the socials and the few deep cuts (practice
* tapes, BBC sessions) neither Deezer nor Apple carries, and never edits a
* record those two already own.
*
* Two failure modes this function owns:
* - MB can 503 under per-IP contention. A fully empty result gets one
* delayed retry — failures are not cached backend-side, so it is real.
* - The session page cache used to hold the RAW backend page forever, and
* the cached fast path skipped this pass entirely — so a warm revisit
* silently LOST the supplement it had on the cold visit ("A ? of WHEN"
* vanishing from Panda Bear). The merged page is now written back into
* the cache and stamped `identity_merged`, and the fast path re-runs this
* pass for cached pages that lack the stamp. */
async function applyArtistIdentity(artistId, active) {
try {
const name = catalog.artistPage?.artist?.name ?? "";
let identity = await api.catalogArtistIdentity(artistId, name);
// Socials are a separate deferred command now, so the identity
// payload's socials are ALWAYS empty — they can no longer prove "MB
// knows this artist" here, and waiting for the retry below would
// delay the footer links 8 s for artists MB carries without release
// groups. Fire them independently before the retry decision.
void applyArtistSocials(artistId, active);
const empty = !(identity?.albums?.length || identity?.type_hints?.length);
if (empty) {
// Transient-failure retry. For a genuinely album-less MB artist
// both calls answer from the URL caches, so the second pass
// costs no MusicBrainz traffic — only this deliberate delay.
await new Promise((resolve) => setTimeout(resolve, 8000));
if (!active()) return;
identity = await api.catalogArtistIdentity(artistId, name);
}
if (!active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
// MB identity extras are always albums, so only rows that could BE
// that album may block one: a same-title SINGLE is a different
// release (the "Phantom Island" shape — a 2024 single and a 2025
// album), and letting it occupy the title made the real album vanish
// from the page.
const seen = new Set(
(current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => normText(a.title)),
);
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
// An MB row whose title is a PREFIX of an existing row's full title
// is the same work Deezer carries as a qualified variant —
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
// (Deluxe)". Injecting it adds a second card with no track count.
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
// distinct: a bare numeral/volume marker never counts as a variant
// qualifier. normText already strips brackets, so compare against
// the raw title normalized without stripping.
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
// Same single-typed exemption as `seen`: only album-like rows may
// claim to be a variant of an MB album.
const existingRaw = (current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => rawNorm(a.title));
const sequelTail = (tail) =>
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
tail.trim(),
);
const coveredByVariant = (title) => {
const base = rawNorm(title);
if (base.length < 8) return false;
return existingRaw.some(
(t) =>
t.length > base.length
&& t.startsWith(base)
&& !sequelTail(t.slice(base.length)),
);
};
const extra = identityAlbums.filter(
(a) => a?.title && !seen.has(normText(a.title)) && !coveredByVariant(a.title),
);
const socials = identity?.socials ?? [];
// MB's authoritative primary/secondary types. The view matches these
// to displayed releases by title and re-files any the provider
// mislabeled "album" (compilations, soundtracks…).
const typeHints = identity?.type_hints ?? [];
// Guest appearances, dropped only where MusicBrainz can arbitrate.
//
// Deezer's /artist/{id}/albums returns records the artist merely
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
// Thief's discography that way. The backend flags them (`appears_on`:
// the release's primary artist is someone else), but that flag ALONE
// must never delete a row: Deezer marks every contributor "Main", so a
// guest spot looks exactly like a genuine joint album — Kanye West's
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
// erased from his page.
//
// MusicBrainz separates them, because it credits a real collaboration
// under BOTH artists: querying Kanye's own MBID returns "Watch the
// Throne", while Mid-Air Thief's returns only his two real albums. So a
// flagged row survives if MB credits it and is dropped if MB does not
// — and ONLY when MB actually answered for this artist. No MB albums
// means no arbiter, and every row is kept rather than guessed at, which
// keeps thin-coverage artists (the obscure end, where this matters
// most) from losing real releases.
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
const ownAlbums = identityAlbums.length
? (current.albums ?? []).filter(
(a) => !a?.appears_on || mbTitles.has(normText(a.title)),
)
: (current.albums ?? []);
const merged = {
...current,
identity_merged: true,
albums: extra.length
? [...ownAlbums, ...extra].sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
: ownAlbums,
socials: socials.length ? socials : current.socials,
typeHints: typeHints.length ? typeHints : current.typeHints,
};
catalog.artistPage = merged;
// Write the supplemented page back into the session cache: a warm
// revisit must not render less than the cold visit did. Overwriting
// a pending entry is safe — cachedArtistRequest only installs its
// own result while its promise is still the stored value.
const key = artistCacheKey(artistId);
if (key) {
catalogArtistPageCache.delete(key);
catalogArtistPageCache.set(key, merged);
trimArtistRequestCache(catalogArtistPageCache);
}
savePageReplay(artistId, merged);
} catch {
// Best-effort supplement: the page is complete without it.
}
}
/** Deferred footer links: merged whenever they arrive, never blocking the
* identity pass. Cached back into the session page cache like the identity
* merge, so a warm revisit keeps its links. */
async function applyArtistSocials(artistId, active) {
try {
const socials = await api.catalogArtistSocials(artistId);
if (!socials?.length || !active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
if (current.socials?.length) return;
const merged = { ...current, socials };
catalog.artistPage = merged;
const key = artistCacheKey(artistId);
if (key) {
catalogArtistPageCache.delete(key);
catalogArtistPageCache.set(key, merged);
trimArtistRequestCache(catalogArtistPageCache);
}
savePageReplay(artistId, merged);
} catch {
// Footer links are decorative; missing them is fine.
}
}
export async function openCatalogArtist(artistId, seedArtist = null) {
cancelCatalogArtistWarmup();
catalogSearchRequest += 1;
const request = ++artistPageRequest;
const artistKey = artistCacheKey(artistId);
if (!artistKey) return;
// After the bail-out above, so a request we never act on cannot scroll the
// page the reader is still on.
catalog.artistOpenSeq += 1;
activateCoverUpgradeScope(artistId);
ui.view = "catalogArtist";
const active = () =>
request === artistPageRequest
&& ui.view === "catalogArtist"
&& sameArtistId(catalog.artistPage?.artist?.id, artistId);
const providedSeed = sameArtistId(seedArtist?.id, artistId)
? rememberArtistSeed(seedArtist)
: null;
const remembered =
providedSeed
?? touchCache(artistSeedById, artistKey)
?? null;
const providerLogosReady = getCatalogArtistProviderLogos(artistId).catch(() => null);
const loadFullArtistArt = () =>
getCatalogArtistIdentityArt(artistId)
.then((art) => mergeCatalogArtistArt(artistId, art, active))
.catch(() => {});
let completed = resolvedArtistCacheValue(catalogArtistPageCache, artistId);
if (completed && !sameArtistId(completed.artist?.id, artistId)) {
catalogArtistPageCache.delete(artistKey);
completed = null;
}
if (completed) {
rememberArtistSeed(completed.artist);
if (completed.logos?.length) rememberProviderLogoStatus(artistId, true);
catalog.artistPage = {
...completed,
art_preview: false,
loading_shell: false,
partial_error: null,
hero_seed_banner: null,
// A positive curated set is identity-safe to reuse. An earlier
// negative is not: wait for this generation's short-TTL provider
// request before authorizing any cover-derived fallback.
provider_logos_settled: Boolean(completed.logos?.length),
cover_logo: null,
placeholder_picture: remembered?.picture ?? completed.artist?.picture ?? null,
};
catalog.artistLoading = false;
catalog.artistStageError = "";
catalog.error = "";
void resolveCoverLogoAvailability(artistId, completed, providerLogosReady, active);
void loadFullArtistArt();
// A cached page from before the MB pass landed (or from a visit
// where it failed) is missing its supplement — the fast path must
// not render less than the cold path did.
if (!completed.identity_merged) void applyArtistIdentity(artistId, active);
else if (!completed.socials?.length) void applyArtistSocials(artistId, active);
return;
}
// The search portrait is a small square image — it must never enter the
// real hero-source list (banner/banners feed the carousel and 4K finisher).
// It stays a presentation-only placeholder threaded through every state.
const seedPicture = remembered?.picture ?? null;
const replay = loadPageReplay(artistId);
catalog.artistPage = replay
? {
// Stale stand-in from the last visit: the final classified album
// list paints immediately while every cold stage below re-runs
// and replaces it. Art fields start empty on purpose — the art
// pipeline owns them.
...replay,
logos: [],
provider_logos_settled: false,
banner: null,
banners: [],
banner_credit: null,
banner_custom: false,
art_preview: true,
loading_shell: false,
replayed: true,
partial_error: null,
hero_seed_banner: null,
cover_logo: null,
placeholder_picture: seedPicture ?? replay.artist?.picture ?? null,
}
: {
artist: {
id: remembered?.id ?? artistId,
name: remembered?.name ?? "",
picture: seedPicture,
fans: remembered?.fans ?? null,
album_count: remembered?.album_count ?? null,
},
logos: [],
provider_logos_settled: false,
banner: null,
banners: [],
banner_credit: null,
banner_custom: false,
popular_tracks: [],
albums: [],
top_genre: null,
socials: [],
art_preview: true,
loading_shell: true,
partial_error: null,
hero_seed_banner: null,
placeholder_picture: seedPicture,
};
catalog.artistLoading = true;
catalog.artistStageError = "";
catalog.error = "";
// A persisted positive mark can paint over the identity shell as soon as
// the fast provider-logo check confirms this artist has no provider logo.
// Provider artists start their complete artwork stage immediately; a cold
// no-provider artist reserves the network for cover generation first.
void resolveCoverLogoAvailability(artistId, null, providerLogosReady, active);
// Bing discovery is independent of cover-logo OCR. Run both under their own
// bounded backend limits so neither user-visible asset sits behind the
// other's multi-second pipeline.
void loadFullArtistArt();
// A single identity-safe source can arrive before the complete provider
// tier/list. It feeds only the first preview; the final artwork response
// still owns carousel selection and 4K finishing.
void getCatalogArtistHeroSeed(artistId)
.then((seed) => {
if (!active() || !seed?.banner) return;
const current = catalog.artistPage;
if (
!current
|| !sameArtistId(current.artist?.id, artistId)
|| current.banners?.length
|| current.banner_custom
) {
return;
}
catalog.artistPage = {
...current,
hero_seed_banner: seed.banner,
banner_custom: seed.banner_custom ?? false,
loading_shell: false,
};
})
.catch(() => {});
// Warm the MusicBrainz identity pass NOW instead of after the full page
// lands. Its 4-6 requests serialize behind MusicBrainz's mandatory
// 1 req/s gate (measured 3.7-8.5 s — most of a cold page's
// time-to-fully-sorted), so starting it beside the page load hides
// almost all of that behind work that was happening anyway. Fire and
// forget: applyArtistIdentity later re-asks with the authoritative name
// and rides the warmed URL caches; results here are deliberately unused.
let identityWarmed = false;
const warmArtistIdentity = (name) => {
if (identityWarmed || !name) return;
identityWarmed = true;
void api.catalogArtistIdentity(artistId, name).catch(() => {});
};
warmArtistIdentity(remembered?.name);
// One-round-trip content stage: popular tracks and the first curated
// releases become usable while exhaustive classification continues.
void getCatalogArtistOverview(artistId)
.then((overview) => {
warmArtistIdentity(overview?.artist?.name);
if (!active()) return;
const current = catalog.artistPage;
if (
!current
|| !sameArtistId(current.artist?.id, artistId)
|| current.art_preview === false
|| !sameArtistId(overview.artist?.id, artistId)
) return;
rememberArtistSeed(overview.artist);
catalog.artistPage = {
...current,
artist: { ...current.artist, ...overview.artist },
// A replayed page already shows the last visit's FINAL
// classified albums; the overview's raw first-50 would be a
// strict downgrade. Fresh albums arrive with the full page.
...(current.replayed
? {}
: {
popular_tracks: overview.popular_tracks ?? [],
albums: overview.albums ?? [],
}),
loading_shell: false,
placeholder_picture:
current.placeholder_picture ?? overview.artist?.picture ?? null,
};
})
.catch(() => {});
try {
// Overview, identity art, and the first hero all remain immediate.
// Give rapid back-to-back navigation a brief chance to settle before
// starting the exhaustive release-classification request; stale pages
// should not consume provider/backend work merely to fill a cache.
await new Promise((resolve) => setTimeout(resolve, 180));
if (!active()) return;
const page = await getCatalogArtistPage(artistId);
if (active() && sameArtistId(page?.artist?.id, artistId)) {
rememberArtistSeed(page.artist);
const staged = catalog.artistPage;
const stagedHasArt = sameArtistId(staged?.artist?.id, artistId);
const finalBanners = page.banner_custom
? page.banners ?? []
: stagedHasArt && staged?.banners?.length
? staged.banners
: page.banners ?? [];
const finalLogos = stagedHasArt && staged?.logos?.length
? staged.logos
: page.logos ?? [];
rememberProviderLogoStatus(artistId, finalLogos.length > 0);
catalog.artistPage = {
...page,
// A replayed page carries the last visit's MB type hints AND
// socials; the fresh page always arrives with both empty
// (socials are a deferred command now), so dropping them here
// would let filing/footer visibly regress for the moment
// before the identity/socials passes re-merge them.
...(stagedHasArt && staged?.typeHints?.length
? { typeHints: staged.typeHints }
: {}),
...(stagedHasArt && staged?.socials?.length
? { socials: staged.socials }
: {}),
banner: page.banner_custom
? page.banner
: stagedHasArt
? staged?.banner ?? page.banner ?? null
: page.banner ?? null,
banners: finalBanners,
logos: finalLogos,
provider_logos_settled: true,
art_preview: false,
loading_shell: false,
partial_error: null,
hero_seed_banner:
finalBanners.length ? null : staged?.hero_seed_banner ?? null,
placeholder_picture:
staged?.placeholder_picture ?? page.artist?.picture ?? null,
cover_logo:
finalLogos.length
? null
: staged?.cover_logo ?? null,
cover_logo_pending: staged?.cover_logo_pending ?? false,
};
// MusicBrainz last pass — shared with the cached fast path, see
// applyArtistIdentity for the semantics.
void applyArtistIdentity(artistId, active);
// No provider logo for this artist → try to lift a wordmark from the
// album covers (band name printed as clean text). Deferred, cached,
// additive-only; on failure the plain text name stays.
void resolveCoverLogoAvailability(artistId, page, providerLogosReady, active);
}
} catch (e) {
if (active()) {
void loadFullArtistArt();
catalog.error = String(e);
catalog.artistStageError = "Some release details could not be loaded.";
if (catalog.artistPage) {
catalog.artistPage = {
...catalog.artistPage,
art_preview: false,
loading_shell: false,
partial_error: catalog.artistStageError,
};
}
}
} finally {
if (request === artistPageRequest) catalog.artistLoading = false;
}
}
/** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a
* track to highlight/scroll to (used by the now-playing song link). */
export async function openCatalogAlbum(albumId, opts = {}) {
const albumKey = albumCacheKey(albumId);
if (!albumKey) return;
// A verified-id navigation supersedes any still-running name search.
albumResolveRequest += 1;
const request = ++albumPageRequest;
ui.view = "catalogAlbum";
catalog.albumPage = null;
catalog.albumLoading = true;
catalog.pendingTrackId = opts.trackId ?? null;
catalog.error = "";
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
try {
const page = await api.catalogAlbum(albumId);
if (!sameAlbumId(page?.album?.id, albumKey)) {
throw new Error("catalog album returned a mismatched album identity");
}
if (!active()) return;
catalog.albumPage = page;
// No catalog trackId (e.g. local playback) — match the song by title.
if (catalog.pendingTrackId == null && opts.trackTitle) {
const t = page.tracks.find(
(tr) => catNorm(tr.title) === catNorm(opts.trackTitle),
);
if (t) catalog.pendingTrackId = t.id;
}
} catch (e) {
if (active()) catalog.error = String(e);
} finally {
if (request === albumPageRequest) catalog.albumLoading = false;
}
}
const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
/** Resolve an artist by name (cached) and open their catalog page — used by
* the now-playing artist link, which only knows the name. */
export async function openArtistByName(name) {
const norm = (name || "").trim().toLowerCase();
if (!norm) return;
let id = artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined;
if (id !== undefined) {
const cachedSeed = artistSeedCache.has(norm)
? touchCache(artistSeedCache, norm)
: null;
if (id != null) openCatalogArtist(id, cachedSeed);
return;
}
// A name-only Now Playing link still navigates immediately. Identity
// resolution replaces this shell in place instead of holding the old view
// behind a catalog search.
const resolveRequest = ++artistPageRequest;
ui.view = "catalogArtist";
catalog.artistLoading = true;
catalog.error = "";
catalog.artistStageError = "";
catalog.artistPage = {
artist: { id: null, name, picture: null, fans: null, album_count: null },
logos: [],
provider_logos_settled: false,
banner: null,
banners: [],
banner_custom: false,
popular_tracks: [],
albums: [],
top_genre: null,
socials: [],
art_preview: true,
loading_shell: true,
partial_error: null,
placeholder_picture: null,
};
try {
const res = await api.catalogSearch(name);
const artists = res.artists ?? [];
// Prefer the most-followed match so a shared name ("Pond", "Panda
// Bear") resolves to the canonical artist, not an obscure namesake
// that Deezer's search happens to list first.
const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm);
const pool = exact.length ? exact : artists;
const best = pool.reduce(
(top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top),
null,
);
id = best?.id ?? null;
setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX);
if (best) rememberArtistSeed(best);
if (
id != null
&& resolveRequest === artistPageRequest
&& ui.view === "catalogArtist"
) {
return openCatalogArtist(id, best);
}
if (resolveRequest === artistPageRequest && id == null) {
catalog.error = "Artist not found.";
catalog.artistPage = null;
}
} catch (error) {
// Only a successful empty search is safe to negative-cache. A
// transport/provider failure must remain retryable on the next click.
if (resolveRequest === artistPageRequest) {
catalog.error = String(error);
catalog.artistPage = null;
}
} finally {
if (resolveRequest === artistPageRequest) catalog.artistLoading = false;
}
}
/** Resolve an album by artist + title (via catalog search) and open its page —
* used by the now-playing album/song links when there's no catalog albumId
* (e.g. a local library track). */
export async function openAlbumByName(artist, title, opts = {}) {
const request = ++albumResolveRequest;
if (opts.albumId != null) return openCatalogAlbum(opts.albumId, opts);
if (!artist || !title) return;
try {
const res = await api.catalogSearch(`${artist} ${title}`);
if (request !== albumResolveRequest) return;
const na = catNorm(artist);
const nt = catNorm(title);
// A title-only or first-result fallback can cross into a namesake's
// catalog. Without a verified id, both artist and title must match.
const match = res.albums?.find(
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
);
if (match) openCatalogAlbum(match.id, opts);
} catch {
// no album match — leave the view as-is
}
}
/** Save the file(s) you're already streaming into the library — no re-search,
* no re-source. `scope`: "track" (current) or "album" (all streamed tracks in
* the queue). Downloads the KNOWN peer file(s) directly (skipping the search
* that made the old button fail) → tag/organize pipeline. Returns { ok, msg }
* for a UI toast. */
export async function saveStream(scope) {
const items =
scope === "album"
? player.queue.filter((it) => it.stream)
: player.queue[player.queueIndex]?.stream
? [player.queue[player.queueIndex]]
: [];
if (items.length === 0) return { ok: false, msg: "Nothing streaming" };
try {
// Cached tracks organize instantly; only un-streamed ones download.
const [organized, queued] = await api.saveToLibrary(
items.map((it) => ({
username: it.stream.username,
path: it.path,
size: it.stream.size,
})),
);
let msg;
if (queued > 0) {
msg = organized > 0 ? `Saved ${organized} · downloading ${queued}` : `Downloading ${queued}…`;
} else {
msg = organized > 1 ? `Saved ${organized} to library` : "Saved to library";
}
return { ok: true, msg };
} catch {
return { ok: false, msg: "Couldn’t save" };
}
}
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
function currentArtistAlbum() {
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
}
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
export function openCurrentArtist() {
const [artist] = currentArtistAlbum();
if (artist) openArtistByName(artist);
}
export function openCurrentAlbum() {
if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId);
return;
}
const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album);
}
export function openCurrentSong() {
const trackTitle = player.track?.title;
if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId, {
trackId: player.catalogRef.trackId,
trackTitle,
});
return;
}
const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album, { trackTitle });
}
// ---- Now Playing discovery (more-from-artist / fans-also-like / about) ----
export const discovery = $state({
artist: null, // normalized name currently loaded (guards against races)
loading: false,
artistId: null,
pageLoaded: false,
pageWanted: false,
picture: null,
fans: null,
albumCount: null,
albums: [], // discography → "More from"
related: [], // "Fans also like"
bio: null, // Wikipedia summary → "About the artist"
});
const bioCache = new Map(); // normalized name → bio text (or null)
const BIO_CACHE_MAX = 64;
/** Deezer's "this artist has no photo" portrait is the md5 of the EMPTY
* STRING. Crucially the API still returns a normal-looking per-artist hash
* that merely REDIRECTS there, and the placeholder has the same dimensions as
* a real portrait — so only the redirect target identifies it. The response's
* final URL is readable (the CDN sends CORS headers), and the fetch warms the
* same image the card is about to display. */
const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e";
const portraitProbeCache = new Map(); // picture url → is-placeholder
async function deezerPortraitMissing(url) {
if (!url) return true;
if (portraitProbeCache.has(url)) return touchCache(portraitProbeCache, url);
let missing = false;
try {
const response = await fetch(url);
missing = response.ok && response.url.includes(DEEZER_EMPTY_PICTURE_HASH);
} catch {
// Offline or blocked: keep the portrait rather than guess it away.
missing = false;
}
setBoundedMap(portraitProbeCache, url, missing, 64);
return missing;
}
/** Short artist bio, resolved by the backend through the artist's OWN
* MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw
* browser fetch that displayed Wikipedia's FIRST hit for
* " musician band" with no verification, which is how the panel came
* to describe Mr. Whitekeys under Camping in Alaska and Crazy Horse under
* Crazy P. It also bypassed the app's Wikimedia UA/pacer/cache. Needs the
* catalog id to anchor identity, so it runs after the id resolves. */
async function fetchArtistBio(artistId, name) {
try {
return (await api.catalogArtistBio(artistId, name)) ?? null;
} catch {
return null;
}
}
/** Resolve the playing artist's catalog id, then load their discography +
* related artists for the Now Playing discovery sections. Guarded by the
* normalized name so rapid track changes don't clobber each other. */
export async function loadDiscovery(artistName) {
const norm = (artistName || "").trim().toLowerCase();
if (!norm) {
discovery.artist = null;
return;
}
const resuming = discovery.artist === norm;
if (resuming && discovery.pageLoaded) return;
if (resuming && discovery.loading) {
// If the panel reopened after the delayed page check already skipped,
// let the in-flight overview request perform one exhaustive retry
// before it settles instead of losing refinement permanently.
if (ui.nowPlaying) discovery.pageWanted = true;
return;
}
discovery.artist = norm;
discovery.loading = true;
discovery.pageWanted = ui.nowPlaying;
if (!resuming) {
discovery.pageLoaded = false;
discovery.artistId = null;
discovery.picture = null;
discovery.fans = null;
discovery.albumCount = null;
discovery.albums = [];
discovery.related = [];
discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null;
}
try {
let id = discovery.artistId
?? (artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined);
if (id === undefined) {
const res = await api.catalogSearch(artistName);
const match =
res.artists?.find((a) => a.name.trim().toLowerCase() === norm) ??
res.artists?.[0] ??
null;
id = match?.id ?? null;
setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX);
if (match) rememberArtistSeed(match);
}
if (discovery.artist !== norm) return; // track changed while searching
if (id == null) return;
discovery.artistId = id;
// The bio is identity-anchored, so it can only start once the catalog
// id is known.
if (!bioCache.has(norm)) {
fetchArtistBio(id, artistName).then((bio) => {
// Only PIN a real answer. A null is usually a transient
// MusicBrainz throttle (its 1 req/s gate is shared with the
// identity pass and the library warmer), and caching that
// would blank the card for the rest of the session even
// though the very next attempt succeeds. Retries are cheap —
// every tier is disk cached for 24 h.
if (bio) setBoundedMap(bioCache, norm, bio, BIO_CACHE_MAX);
if (discovery.artist === norm) discovery.bio = bio;
});
}
const overviewReady = discovery.albums.length
? Promise.resolve(null)
: getCatalogArtistOverview(id).catch(() => null);
// The Now Playing panel can paint its useful discovery content from
// the overview. Delay exhaustive classification until the artist and
// open panel have remained stable, avoiding invisible work after a
// rapid skip or panel close.
const pageReady = (async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
if (discovery.artist !== norm || !ui.nowPlaying) return null;
discovery.pageWanted = false;
return getCatalogArtistPage(id).catch(() => null);
})();
const [overview, related] = await Promise.all([
overviewReady,
api.catalogRelated(id).catch(() => []),
]);
if (discovery.artist !== norm) return;
if (overview) {
rememberArtistSeed(overview.artist);
discovery.picture = overview.artist.picture ?? null;
// A Deezer "picture" can exist and still be the grey silhouette
// (see deezerPortraitMissing). Those artists usually DO have
// provider/Bing hero art — the artist page already resolves it —
// so borrow that instead of showing a placeholder.
void (async () => {
const portrait = discovery.picture;
if (portrait && !(await deezerPortraitMissing(portrait))) return;
const seed = await getCatalogArtistHeroSeed(id).catch(() => null);
if (
discovery.artist === norm
&& discovery.picture === portrait
&& seed?.banner
) discovery.picture = seed.banner;
})();
discovery.fans = overview.artist.fans ?? null;
discovery.albumCount =
overview.artist.album_count ?? overview.albums.length;
discovery.albums = overview.albums ?? [];
}
discovery.related = related ?? [];
let page = await pageReady;
if (discovery.artist !== norm) return;
if (!page && discovery.pageWanted && ui.nowPlaying) {
discovery.pageWanted = false;
page = await getCatalogArtistPage(id).catch(() => null);
if (discovery.artist !== norm) return;
}
if (page) {
rememberArtistSeed(page.artist);
discovery.picture = page.artist.picture ?? null;
discovery.fans = page.artist.fans ?? null;
discovery.albumCount = page.artist.album_count ?? page.albums.length;
discovery.albums = page.albums ?? [];
discovery.pageLoaded = true;
discovery.pageWanted = false;
}
} catch {
// leave whatever loaded; sections just stay empty
} finally {
if (discovery.artist === norm) discovery.loading = false;
}
}
// ---- auto-sourcing resolver ----
// The catalog is the app's front door: picking a song or album quietly runs
// a Soulseek search, ranks the sources, and streams/downloads the winner.
// The manual Search view stays available as a fallback.
export const resolver = $state({
active: false,
label: "",
error: "",
});
const RESOLVER_AUDIO_QUALITY = {
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
};
// Formats that decode progressively while the file is still downloading.
// MP4-family files (m4a/aac) put their index at the end and only play once
// complete, so they're save-only.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
// User source-selection preferences, independent per mode. Loaded from
// settings at startup and refreshed by the Settings view after a commit;
// defaults reproduce the pre-feature behavior exactly.
const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" };
export const sourcePrefs = {
stream: { ...DEFAULT_SOURCE_PREFS },
download: { ...DEFAULT_SOURCE_PREFS },
};
export async function refreshSourcePrefs() {
try {
const s = await api.getSettings();
sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) };
sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) };
} catch {
// settings unavailable (fresh install / backend restart) → defaults
}
}
void refreshSourcePrefs();
/** Whether a peer file satisfies the user's quality window for this mode.
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
* bitrate at or above it — a peer that hides its bitrate does not get the
* benefit of the doubt when the user asked for a minimum. Thresholds sit
* slightly under nominal so VBR files reporting 317 kbps still count as 320. */
function passesSourcePrefs(f, prefs) {
const lossless = LOSSLESS_EXTS.has(f.extension);
if (prefs.max === "lossy_only" && lossless) return false;
switch (prefs.min) {
case "lossless":
if (!lossless) return false;
break;
case "kbps320":
if (!lossless && (f.bitrate ?? 0) < 315) return false;
break;
case "kbps256":
if (!lossless && (f.bitrate ?? 0) < 250) return false;
break;
case "kbps192":
if (!lossless && (f.bitrate ?? 0) < 186) return false;
break;
}
return true;
}
/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers
* overwhelmingly name files unaccented; without folding, normText simply
* DELETED accented letters ("Jóga" → "jga") and the request could never
* match the file. */
const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, "");
const normText = (s) =>
asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, "");
/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…",
* "Album (Deluxe Edition)"). Peer searches AND-match every word, so query
* with just the primary segment, capped to a few words. */
function simplifyTitle(s) {
const first = (s ?? "").split(/[;:(\[]/)[0].trim();
const words = (first || s || "").split(/\s+/).slice(0, 6).join(" ");
return words || s || "";
}
// Words that mark an *edition* of a release, not a distinct release. Used to
// tell an edition suffix (noise, safe to drop) from a variant name that names
// a different album — critical for self-titled records where the base title
// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)".
const ALBUM_EDITION_RE =
/\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i;
/** The disambiguating variant name inside a title's brackets — e.g. "Blue
* Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe
* Edition / Red Album)" — skipping pure edition markers. "" when the brackets
* only hold edition noise (or there are none). */
export function albumDisambiguator(title) {
const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]);
for (const inner of groups) {
for (const part of inner.split(/\s*[/|;]\s*/)) {
const p = part.trim();
if (p && !ALBUM_EDITION_RE.test(p)) return p;
}
}
return "";
}
/** Search-query title-part for a release: the simplified title, plus the
* disambiguating variant name when there is one, so distinct self-titled
* albums search for their own colour instead of colliding on the band name. */
function albumQueryTitle(album) {
const base = simplifyTitle(album.title);
// Fall back to a caller-supplied variant (the family's colour) when the
// title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album
// but names no colour, so without this it searches bare "Weezer".
const d = albumDisambiguator(album.title) || album.variant || "";
return d ? `${base} ${d}` : base;
}
// Server-distributed excluded search phrases (Soulseek message 160). A query
// containing one is NEVER routed by the server — it returns zero responses
// network-wide with no error. Verified live: every query containing "mgmt"
// was silent while "little dark age" alone returned 738 responses. The list
// arrives by event after login and is also fetchable on demand (covers a
// WebView reload); the resolver strips these terms before they reach the wire.
let excludedSearchPhrases = [];
export function setExcludedSearchPhrases(list) {
excludedSearchPhrases = (Array.isArray(list) ? list : [])
.map((p) => String(p ?? "").toLowerCase().trim())
.filter(Boolean);
}
void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {});
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** Query with server-excluded phrases removed (whole-word, any position) and
* whitespace collapsed. Returns "" when nothing usable remains. */
function sanitizeSearchQuery(query) {
let out = ` ${query ?? ""} `;
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
}
return out.replace(/\s+/g, " ").trim();
}
/** Run a background source search (own token — the Search view is untouched)
* and collect responses. Ends early once `goodCount()` finds enough.
*
* The early exit is evaluated on a 250ms TICK, not only when a batch
* arrives: peers deliver most results within the first second and then go
* quiet, and the old on-batch-only check meant a plentiful-but-fast search
* still sat out the full window waiting for one more batch that never came
* (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting).
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
* a workable set at 1800ms, and the full window only runs when results
* are genuinely sparse. */
async function collectSources(query, maxMs, goodCount) {
const token = await api.startSearch(query);
const responses = [];
let unlisten = null;
await new Promise((resolve) => {
const started = performance.now();
const timer = setTimeout(() => {
clearInterval(tick);
resolve();
}, maxMs);
const tick = setInterval(() => {
if (!goodCount) return;
const elapsed = performance.now() - started;
const good = goodCount(responses);
if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) {
clearTimeout(timer);
clearInterval(tick);
resolve();
}
}, 250);
unlisten = api.onEvent("soulseek:search-results", (batch) => {
for (const r of batch) if (r.token === token) responses.push(r);
});
});
unlisten?.();
api.stopSearch(token).catch(() => {});
return responses;
}
/** Queries measured silent (zero responses) recently — skipped for a while
* so a poisoned rung ("mgmt …") costs its discovery timeout once, not on
* every album/track of the same artist. */
const silentQueries = new Map();
const SILENT_QUERY_TTL_MS = 10 * 60 * 1000;
function querySilent(q) {
const at = silentQueries.get(q);
if (at == null) return false;
if (performance.now() - at > SILENT_QUERY_TTL_MS) {
silentQueries.delete(q);
return false;
}
return true;
}
// Terms PROVEN poisoned this session: a rung containing the term was silent
// while a term-less rung answered — the network is fine, the term is dead
// (the "mgmt" case; the server's own excluded list does not admit it).
// Applied exactly like server-excluded phrases, so the second album by the
// same artist never pays the discovery timeout again.
const learnedPoisonedTerms = new Set();
/** Estimated seconds until this peer's file has fully arrived: transfer time
* from the file's size and the peer's advertised upload speed (bytes/sec),
* plus a rough wait for a busy slot / queued transfers. Pessimistic where the
* peer hides its numbers — an unknown speed must not look like a fast one. */
function estimatedTransferSecs(r, file) {
const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large
const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow
let secs = size / speed;
if (!r.has_free_upload_slot) secs += 90;
secs += Math.min(r.queue_length ?? 0, 50) * 45;
return secs;
}
function scoreSource(r, file, prefs = null) {
const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0;
if (!q) return -1;
const bitrate = file.bitrate;
// Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus
// queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer
// (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This
// only reorders sources that already passed the user's min/max quality
// window (passesSourcePrefs runs before scoring), so "fastest" can never
// dip below the configured floor. Format/bitrate remain as tie-breakers.
if (prefs?.priority === "speed") {
// Positive baseline: callers treat score <= 0 as "reject this
// format", and a slow, deep-queued peer must rank last, not vanish.
const eta = Math.min(estimatedTransferSecs(r, file), 3_600);
let s = 100_000 - eta * 25;
s += q * 50;
if (bitrate) s += Math.min(bitrate, 1500) / 50;
return s;
}
let s = q * 1000;
if (bitrate) s += Math.min(bitrate, 1500) / 10;
if (r.has_free_upload_slot) s += 600;
s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000;
s -= Math.min(r.queue_length ?? 0, 50) * 12;
return s;
}
function requireOnline() {
if (app.connection !== "online") {
app.showLogin = true;
return false;
}
return true;
}
// Markers that name a DIFFERENT RECORDING of the same song — live takes,
// remixes, demos, covers, radio edits… `normText` strips brackets, so
// "Island in the Sun (Live at X).mp3" matches the studio request perfectly;
// these are tested against the RAW filename + folder to catch that. Matching
// is symmetric: a candidate must carry exactly the markers the requested
// title carries — no live file for a studio request, no studio file for an
// explicitly "(Live)" catalog track.
const TRACK_VARIANT_MARKERS = [
/\blive\b/i,
/\bunplugged\b/i,
/\bacoustic\b/i,
/\bremix(?:es|ed)?\b/i,
/\brmx\b/i,
/\brework(?:ed)?\b/i,
/\bdemo(?:s)?\b/i,
/\binstrumental\b/i,
/\bkaraoke\b/i,
/\bcover(?:s|ed)?\b/i,
/\btribute\b/i,
/\b(?:radio|single)\s+edit\b/i,
/\b(?:club|extended|dub|dance)\s+mix\b/i,
/\bmedley\b/i,
/\bmashup\b/i,
/\bsession(?:s)?\b/i,
/\brehearsal\b/i,
/\bouttake(?:s)?\b/i,
/\balternate\b/i,
/\btake\s+\d+\b/i,
/\bre-?recorded\b/i,
// "'s Version" re-records (Taylor's Version and kin): a different
// recording of the song, matched symmetrically like every marker here —
// found live by the source audit when a request for "22" top-ranked
// "22 (Taylor's Version)".
/\b\w+['’]s\s+version\b/i,
/\bsped\s+up\b/i,
/\bslowed\b/i,
/\b8[\s-]?bit\b/i,
];
// Checked against the filename only: "Reprise" is also a record label
// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject.
const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i];
/** True when the candidate names a different version of the song than the
* request asked for (either direction). */
function variantMismatch(wantedTitle, candidateName, candidateFolder) {
const cand = `${candidateFolder}\\${candidateName}`;
for (const re of TRACK_VARIANT_MARKERS) {
if (re.test(cand) !== re.test(wantedTitle)) return true;
}
for (const re of TRACK_VARIANT_NAME_ONLY) {
if (re.test(candidateName) !== re.test(wantedTitle)) return true;
}
return false;
}
/** A peer file's playtime is the wrong recording when it's far from the
* catalog's known length — most often a ripped 30-second streaming *preview*
* whose filename matches the real track (e.g. a 30s "Long Season" for a
* 41-minute live track). Only judges when BOTH durations are known; the band
* is generous (±20%, min 45s) so gapless trims and a few seconds of trailing
* silence never trip it. */
function durationMismatch(expectSecs, candSecs) {
if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false;
const tol = Math.max(45, expectSecs * 0.2);
return Math.abs(candSecs - expectSecs) > tol;
}
/** Ranked candidate files for a song across all responses (best first).
* `album` (title string, optional) biases toward folders of the release the
* user is actually looking at. `expectSecs` (optional) is the catalog track's
* known length, used to drop preview clips / wrong-length rips. */
function songCandidates(
responses,
title,
streamOnly = false,
album = null,
expectSecs = null,
artist = null,
requireIdentityEvidence = false,
) {
const want = normText(title);
const wantedRaw = title ?? "";
const wantAlbum = album ? normText(simplifyTitle(album)) : "";
const artistKey = artist ? normText(artist) : "";
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
const out = [];
for (const r of responses) {
for (const f of r.files) {
if (!normText(f.name).includes(want)) continue;
if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue;
if (!passesSourcePrefs(f, prefs)) continue;
if (variantMismatch(wantedRaw, f.name, f.folder)) continue;
if (durationMismatch(expectSecs, f.duration)) continue;
let s = scoreSource(r, f, prefs);
if (s <= 0) continue;
const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum);
const artistEvidence =
artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey);
// A rung that dropped the artist term (server-excluded phrase)
// matches by title alone, which happily returns other artists'
// covers of the song. The peer's PATH must then prove identity:
// the artist somewhere in the folder/filename, or the exact
// release folder.
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
continue;
}
// The requested release's own folder beats compilation/misc rips
// (below a format-tier jump, so quality still wins).
if (albumEvidence) s += 800;
if (artistEvidence) s += 400;
// Filename ends with the title → it IS this song, not a longer
// title containing it ("Holiday" vs "Holiday in Cambodia").
if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250;
out.push({ username: r.username, file: f, score: s });
}
}
out.sort((a, b) => b.score - a.score);
return out;
}
/** Exposed for the source-resolution audit harness (session-scratchpad CDP
* probes drive the REAL matching/scoring code against live search responses).
* Not part of the app surface. */
export const _sourceAudit = {
songCandidates,
albumCandidates,
sanitizeSearchQuery,
collectSources,
variantMismatch,
durationMismatch,
asciiFold,
normText,
simplifyTitle,
stopPlayback,
};
/** Resolves true when the stream is audibly playing; false on decode/peer
* failure (status drops to idle) or when nothing plays within `ms`. */
function waitForStreamStart(ms) {
return new Promise((resolve) => {
const t0 = performance.now();
const iv = setInterval(() => {
if (player.status === "playing") {
clearInterval(iv);
resolve(true);
} else if (performance.now() - t0 > ms || player.status === "idle") {
clearInterval(iv);
resolve(false);
}
}, 100);
});
}
/** Ranked candidate folders for an album (best first). `trackCountOverride`
* is the authoritative track count from the catalog tracklist fetch; prefer it
* over `album.track_count`, which is routinely null when Play is pressed on the
* artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */
function albumCandidates(
responses,
album,
streamOnly = false,
trackCountOverride = null,
requireIdentityEvidence = false,
) {
const artistKey = normText(album.artist ?? "");
const wantAlbum = normText(simplifyTitle(album.title));
const wanted = trackCountOverride ?? album.track_count ?? null;
const year = album.release_date?.slice(0, 4) || null;
// Self-titled disambiguation: the "(Blue Album)" variant, and its bare
// colour word, so we can prefer the right folder and reject a wrong-colour
// one that a fuzzy peer search returns anyway.
const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase();
const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null;
const folders = new Map();
for (const r of responses) {
for (const f of r.files) {
const key = `${r.username}|${f.folder}`;
let g = folders.get(key);
if (!g) {
g = { username: r.username, folder: f.folder, files: [], r };
folders.set(key, g);
}
g.files.push(f);
}
}
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
const out = [];
for (const g of folders.values()) {
const audio = g.files.filter(
(f) =>
(streamOnly
? RESOLVER_STREAMABLE.has(f.extension)
: RESOLVER_AUDIO_QUALITY[f.extension])
&& passesSourcePrefs(f, prefs),
);
// Require enough streamable files to plausibly be this release, but
// NEVER default an unknown count to 3 — that silently rejected every
// single/2-track release (whose peer folders hold 1–2 matching files)
// and is exactly why "No streamable album sources found" fired for
// real, obviously-streamable singles like ICARUS. Known count → at most
// 3, floor 1; unknown → just 1.
const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1;
if (audio.length < minAudio) continue;
const top = audio
.map((f) => scoreSource(g.r, f, prefs))
.reduce((a, b) => a + b, 0) / audio.length;
let s = top;
const folderNorm = normText(g.folder);
const albumEvidence = folderNorm.includes(wantAlbum);
const artistEvidence = artistKey && folderNorm.includes(artistKey);
// Artist-less query rung (server-excluded phrase): the folder path
// must prove the release's identity — see songCandidates.
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
continue;
}
if (albumEvidence) s += 400;
if (artistEvidence) s += 300;
// Prefer folders whose track count matches the release.
if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60;
// The release year in the folder path is a strong per-release signal
// (distinguishes reissues and same-named albums).
if (year && g.folder.includes(year)) s += 300;
// Self-titled colour disambiguation: reward the matching "(X Album)"
// folder, and strongly reject a folder that clearly names a different
// colour, so "Weezer (Blue Album)" can't stream from a Red folder.
if (wantColour) {
const folderColours = [
...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g),
].map((m) => m[1]);
if (folderColours.includes(wantColour)) s += 500;
else if (folderColours.length) s -= 900;
}
audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
out.push({ username: g.username, folder: g.folder, files: g.files, audio, score: s });
}
out.sort((a, b) => b.score - a.score);
return out;
}
/** Walk a LADDER of query shapes until one returns responses. Each rung is
* sanitized against the server's excluded-phrase list first (a poisoned term
* silences the whole query), deduplicated, and dropped when sanitizing left
* nothing. Returns `{ responses, artistIncluded }` — `artistIncluded` is
* whether the rung that produced the responses still contained the artist
* term, so candidate filters know to demand artist/album evidence in the
* file path instead (a title-only query happily returns covers and karaoke).
*
* `queries` may be a string (single rung) or an array, best-first. */
async function resolve(label, queries, goodCount, artistTerm = null) {
resolver.active = true;
resolver.label = label;
resolver.error = "";
player.error = "";
try {
const artistKey = artistTerm ? normText(artistTerm) : "";
const seen = new Set();
const ladder = [];
for (const raw of [].concat(queries).filter(Boolean)) {
// Each shape also gets an ASCII-folded rung: peers name files
// unaccented, and the server AND-matches query tokens, so
// "Björk Jóga" finds nothing that "Bjork Joga" finds.
for (const variant of [raw, asciiFold(raw)]) {
const q = sanitizeSearchQuery(variant);
if (!q || seen.has(q)) continue;
seen.add(q);
ladder.push(q);
}
}
// Non-final rungs get a short window: a rung the network answers at
// all answers within ~2s, so a silent (poisoned/warm-up) rung should
// cost 3.5s, not the full budget. Recently-silent rungs are skipped
// outright. The post-login warm-up retry moved BEHIND the ladder: if
// ANY rung answered, connectivity is proven and no retry is owed —
// the old first-rung retry burned 7 extra seconds on every poisoned
// artist query before the fallback rung even ran.
let sawSilentArtistRung = false;
for (let index = 0; index < ladder.length; index++) {
const q = ladder[index];
if (querySilent(q)) continue;
const final = index === ladder.length - 1;
const hasArtist = artistKey && normText(q).includes(artistKey);
const responses = await collectSources(q, final ? 7000 : 3500, goodCount);
if (responses.length) {
// An answered term-less rung after a silent artist rung PROVES
// the artist term is what kills the query — learn it, so the
// artist's other releases skip the dead rung entirely.
if (sawSilentArtistRung && !hasArtist && artistTerm) {
learnedPoisonedTerms.add(artistTerm.toLowerCase().trim());
}
return {
responses,
artistIncluded: !artistKey || normText(q).includes(artistKey),
};
}
if (hasArtist) sawSilentArtistRung = true;
silentQueries.set(q, performance.now());
}
// Whole ladder silent: either the terms are all poisoned or we are in
// the post-login window where the server has not attached us to a
// search parent yet. One quiet retry of the primary rung covers the
// latter (and clears its silent mark on success).
if (ladder.length) {
const q = ladder[0];
const responses = await collectSources(q, 7000, goodCount);
if (responses.length) {
silentQueries.delete(q);
return {
responses,
artistIncluded: !artistKey || normText(q).includes(artistKey),
};
}
}
return { responses: [], artistIncluded: true };
} finally {
resolver.active = false;
resolver.label = "";
}
}
// ---- library-first playback ----
// Clicking play on a catalog album/song that's already saved plays the
// local library copy instead of sourcing a stream from a peer.
/** Library album matching a catalog release (same normalization as the
* artist page's ✓ badges, plus the simplified-title variant so verbose
* catalog editions still match). */
function libraryAlbumFor(artist, title, releaseDate = null) {
const a = normText(artist);
const t1 = normText(title);
if (!a || !t1) return null;
// normText strips ALL bracket content, so base-title comparison alone
// cannot tell self-titled variants apart: "Weezer (Blue Album)" and
// "Weezer (Green Album)" both normalize to "weezer" and would exact-match
// a library copy saved under the bare provider title "Weezer". After the
// base titles agree, identity therefore needs the variant name
// (albumDisambiguator) to agree — or, when either side lacks one, the
// release YEAR to corroborate. A self-titled base carries no identity at
// all (the title is just the artist), so even a bare↔bare title match
// requires the year there. No usable evidence → no local match; streaming
// the right album beats instantly playing the wrong one.
const wantD = normText(albumDisambiguator(title));
// The simplified-title fallback lets a library copy saved under a terse
// name match a verbose catalog EDITION (library "Album" ↔ catalog "Album
// (Deluxe Edition)"); disambiguated variants never take it.
const t2 = wantD ? null : normText(simplifyTitle(title));
const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null;
const selfTitled = t1 === a;
return (
libraryStore.albums.find((al) => {
if (normText(al.artist) !== a) return false;
const lt = normText(al.title);
if (lt !== t1 && !(t2 && lt === t2)) return false;
const libD = normText(albumDisambiguator(al.title));
if (wantD && wantD === libD) return true;
if (wantD !== libD || selfTitled) {
const libYear = Number(al.year) || null;
return wantYear != null && libYear != null && libYear === wantYear;
}
return true;
}) ?? null
);
}
/** The library album matching a catalog release, or null — the same evidence
* rules as library-first playback (variant name + release-year for self-titled
* records). Exposed for the artist page's saved-track ticks. */
export function libraryLocalAlbum(artist, album) {
return libraryAlbumFor(artist, album?.title ?? "", album?.release_date ?? null);
}
/** Whether a catalog release already has a matching copy in the library —
* the same evidence rules as library-first playback, so the artist page's
* ✓ badges and the play routing can never disagree. */
export function libraryHasAlbum(artist, album) {
return Boolean(libraryLocalAlbum(artist, album));
}
/** Locate a track in the library by artist + title, searching only that
* artist's albums. Returns { album, tracks, index } or null. */
async function libraryTrackFor(artist, title) {
const a = normText(artist);
const t = normText(title);
if (!a || !t) return null;
for (const al of libraryStore.albums) {
if (normText(al.artist) !== a) continue;
try {
const tracks = await api.albumTracks(al.id);
const index = tracks.findIndex((tr) => normText(tr.title) === t);
if (index >= 0) return { album: al, tracks, index };
} catch {
// library unavailable — fall through to streaming
}
}
return null;
}
/** Distill a peer file's tags into the quality info the Now Playing panel
* shows (format/bitrate/sample-rate/bit-depth). All fields are optional —
* many clients only report some, so the panel hides what's missing. */
function qualityOf(f) {
if (!f) return null;
const ext = f.extension || (f.name || f.path || "").split(".").pop() || "";
return {
format: ext.toUpperCase(),
bitrate: f.bitrate ?? null,
sampleRate: f.sample_rate ?? null,
bitDepth: f.bit_depth ?? null,
vbr: !!f.vbr,
size: f.size ?? null,
};
}
/** Quality info for a local library track: format from the path, the rest
* from the DB (sample-rate/bit-depth/size backfilled for older files at
* startup). The panel drops whatever's still missing. Null if there's no
* path. `t` is a library Track row. */
function localQuality(t) {
const path = t?.path || "";
const ext = path.split(".").pop() || "";
if (!ext || ext === path) return null;
return {
format: ext.toUpperCase(),
bitrate: t.bitrate_kbps ?? null,
sampleRate: t.sample_rate ?? null,
bitDepth: t.bit_depth ?? null,
vbr: false,
size: t.size ?? null,
};
}
/** Deezer's CDN renders the same cover at whatever size the URL's size
* segment asks for. Track objects carry the 250px list thumb
* (`cover_medium`), which looks terrible blown up in the player — rewrite it
* to the 1000px xl the album pages use. Non-Deezer URLs pass through
* untouched. */
function hqCover(url) {
if (!url) return url;
try {
if (!new URL(url).hostname.endsWith("dzcdn.net")) return url;
} catch {
return url;
}
return url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
"/1000x1000$1.$2",
);
}
/** Responsive card-sized variant of a canonical cover URL. The resolver keeps
* its original/high-resolution winner for the player and album expander; the
* 150px grid should not decode 9-million-pixel Apple masters into GPU memory. */
export function gridCover(url) {
if (!url || url.startsWith("data:")) return url;
try {
const parsed = new URL(url);
if (parsed.hostname.endsWith("dzcdn.net")) {
return url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
"/500x500$1.$2",
);
}
if (parsed.hostname.endsWith("mzstatic.com")) {
parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/500x500bb.jpg");
return parsed.toString();
}
if (parsed.hostname.endsWith("coverartarchive.org")) {
parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-500");
return parsed.toString();
}
} catch {
return url;
}
return url;
}
/** A tiny CDN-rendered variant used for blurred placeholders and palette
* sampling. Deezer and Apple both resize the same source artwork by URL. */
export function tinyCover(url) {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.hostname.endsWith("mzstatic.com")) {
parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/96x96bb.jpg");
return parsed.toString();
}
if (parsed.hostname.endsWith("coverartarchive.org")) {
const originalPath = parsed.pathname;
parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-250");
return parsed.pathname === originalPath && !originalPath.endsWith("/front-250")
? null
: parsed.toString();
}
if (!parsed.hostname.endsWith("dzcdn.net")) return null;
} catch {
return null;
}
const tiny = url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
"/56x56$1.$2",
);
return tiny === url ? null : tiny;
}
/** Find the best source for a song and stream it, failing over to the next
* candidate when a peer never starts sending. Songs already in the library
* play locally (with their album as the queue).
* opts: { cover, trackId, albumId, album, expectSecs } — catalog context for
* art, badges, album-folder source preference, and length verification. */
export async function streamSong(artist, title, opts = {}) {
if (resolver.active) return;
const local = await libraryTrackFor(artist, title);
if (local) {
playAlbum(
local.tracks,
`${local.album.artist} — ${local.album.title}`,
local.index,
local.album.art_path ?? null,
);
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
return;
}
if (!requireOnline()) return;
autoAlbum = null;
player.track = { title, sub: "finding best source…", source: "stream" };
player.status = "buffering";
const { responses, artistIncluded } = await resolve(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, true, opts.album, opts.expectSecs, artist).length,
artist,
);
const candidates = songCandidates(
responses, title, true, opts.album, opts.expectSecs, artist, !artistIncluded,
);
if (candidates.length === 0) {
player.status = "idle";
player.track = null;
resolver.error = `No streamable sources found for “${title}”`;
return;
}
for (const c of candidates.slice(0, 4)) {
player.queue = [
{
path: c.file.path,
title,
sub: artist,
artist,
album: opts.album ?? null,
duration: c.file.duration ?? opts.expectSecs ?? null,
art: null,
artUrl: hqCover(opts.cover) ?? null,
trackId: opts.trackId ?? null,
stream: { username: c.username, size: c.file.size },
quality: qualityOf(c.file),
},
];
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
playQueueIndex(0);
if (await waitForStreamStart(15000)) {
// Keep the album going: resolve + pre-buffer the next album
// track in the background, Spotify-style.
if (opts.albumId) {
autoAlbum = {
albumId: opts.albumId,
albumTitle: opts.album ?? null,
cover: hqCover(opts.cover) ?? null,
tracks: null,
pending: false,
};
maybeExtendAlbumQueue();
}
return;
}
}
stopPlayback();
resolver.error = `Sources for “${title}” aren't responding — try again in a moment`;
}
/** Find the best source for a song and download it into the library. */
export async function saveSong(artist, title) {
if (!requireOnline() || resolver.active) return false;
const { responses, artistIncluded } = await resolve(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, false, null, null, artist).length,
artist,
);
const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0];
if (!best) {
resolver.error = `No sources found for “${title}”`;
return false;
}
await api.downloadFile(best.username, best.file.path, best.file.size);
return true;
}
/** Match a peer file to a catalog track, so queue items carry the tagged
* title instead of the filename.
*
* A track title contained in the filename normally identifies the track — but
* only if it's *specific* to this file. When peers embed the album name in
* every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album
* name is also a track title, that album name is a substring of every filename
* and — being long — used to out-rank each track's real, shorter title, so the
* whole album collapsed to one repeated title. So: ignore any title that
* appears in (roughly) every filename, take the longest *specific* match, and
* otherwise fall back to album order when the folder is a complete album. */
function catalogTrackFor(file, i, files, tracks) {
if (!tracks?.length) return null;
const fname = normText(file.name);
const base = (t) => normText(t.title);
const named = (t) => base(t).length >= 3 && fname.includes(base(t));
// PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds
// exactly the album's track count it's almost always the whole album in
// order, so file `i` is track `i`. That's the only reliable way to tell
// same-named variants apart — "My Name Is Jonas (2024 Remaster)",
// "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base
// title, so filename matching alone can't say which belongs here (it used
// to pick the longest title, i.e. the verbose live/demo one). Only distrust
// the position if the filename clearly names a DIFFERENT song than the one
// sitting there (a reordered/mismatched folder — handled by the backup).
if (files.length === tracks.length) {
const at = tracks[i];
if (!fname || named(at) || !tracks.some(named)) return at;
}
// BACKUP: fuzzy filename→title matching for a partial/reordered folder.
// Drop any title that appears in ~every filename (that's the album name),
// then among the rest prefer the closest duration (disambiguates same-named
// variants), then the longest title.
const contained = tracks.filter(named);
const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0);
const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5));
let pool = contained.filter((t) => ubiquity(t) < ubiquitous);
if (!pool.length) pool = contained;
if (pool.length) {
const dur = file.duration;
if (dur) {
const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur);
// Closest duration wins; on a tie prefer the shorter title, i.e. the
// plain studio version over a longer "(Live…)" / "(Demo…)" variant.
return pool.reduce((a, b) =>
off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a,
);
}
return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a));
}
return files.length === tracks.length ? tracks[i] : null;
}
/** Turn a peer filename into a readable title when no catalog track matches
* it (a genuine bonus/extra file on an expanded edition): drop the extension
* and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a
* title rather than "04. Dragon Ball Z…". Conservative — only strips a number
* that's clearly a track index (has a separator, or a leading zero), so a title
* like "99 Luftballons" is left intact. */
function cleanTrackName(name) {
const stem = (name ?? "").replace(/\.[^.]+$/, "");
const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim();
return stripped || stem;
}
/** Find the best folder for an album; stream it as a queue, failing over to
* the next-best folder when a peer never starts sending. Albums already in
* the library play locally instead. */
export async function streamCatalogAlbum(album) {
if (resolver.active) return;
const local = libraryAlbumFor(album.artist, album.title, album.release_date ?? null);
if (local) {
try {
const tracks = await api.albumTracks(local.id);
if (tracks.length > 0) {
playAlbum(
tracks,
`${local.artist} — ${local.title}`,
0,
local.art_path ?? null,
);
player.catalogRef = { albumId: album.id ?? null, trackId: null };
return;
}
} catch {
// library unavailable — fall through to streaming
}
}
if (!requireOnline()) return;
autoAlbum = null;
player.track = { title: album.title, sub: "finding best source…", source: "stream" };
player.status = "buffering";
// Resolve the authoritative tracklist FIRST. Its length is the true track
// count — Deezer's artist-album LIST omits nb_tracks, so album.track_count
// is routinely null when Play is pressed on the artist page — and its
// titles are what the queue should show instead of peer filenames. Feeding
// the real count into albumCandidates both stops the ≥3-files filter from
// rejecting small releases and lets the folder-count preference pick the
// edition that matches the release (e.g. the 3-track single, not a 7-track
// CDR that would leak bonus tracks into the queue as raw filenames).
const catalogPromise = album.id
? api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
: Promise.resolve(null);
// Overlap the peer search with the tracklist fetch when the release is
// provably multi-track — the search is the long pole, and the tracklist
// round-trip used to serialize in front of it. `liveTrackCount` feeds the
// early-exit counter and tightens itself the moment the tracklist lands.
// A possible single (unknown count, or single/EP typed) keeps the
// sequential order, because a confirmed single must take the SONG path
// and must not race an already-active album resolve.
const provablyMultiTrack =
(album.track_count ?? 0) > 1 || album.record_type === "album";
let liveTrackCount = album.track_count || null;
const goodCount = (rs) => albumCandidates(rs, album, true, liveTrackCount).length;
let resolvePromise = null;
if (provablyMultiTrack) {
resolvePromise = resolve(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
goodCount,
album.artist,
);
}
const catalogTracks = await catalogPromise;
const trackCount = catalogTracks?.length || album.track_count || null;
liveTrackCount = trackCount;
// A confirmed single is one track — stream it through the song path, which
// matches peer files by TITLE (album folder-matching can't identify a lone
// file among a folder's contents) and layers on the live/remix + duration
// hardening. Without this, a single fell into the album path and, with an
// unknown count, was rejected for having fewer than 3 files per folder.
if (trackCount === 1) {
// Rare mistyped release: a search may already be in flight — let it
// finish so the song path's own resolve isn't refused as re-entrant.
if (resolvePromise) await resolvePromise.catch(() => {});
const t = catalogTracks?.[0];
await streamSong(album.artist, t?.title ?? album.title, {
cover: t?.cover ?? album.cover,
trackId: t?.id ?? null,
albumId: album.id ?? null,
album: album.title || null,
expectSecs: t?.duration_secs ?? null,
});
return;
}
const { responses, artistIncluded } = await (resolvePromise
?? resolve(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
goodCount,
album.artist,
));
const candidates = albumCandidates(responses, album, true, trackCount, !artistIncluded);
if (candidates.length === 0) {
player.status = "idle";
player.track = null;
resolver.error = `No streamable album sources found for “${album.title}”`;
return;
}
for (const folder of candidates.slice(0, 3)) {
player.queue = folder.audio.map((f, i) => {
const ct = catalogTrackFor(f, i, folder.audio, catalogTracks);
return {
path: f.path,
title: ct?.title ?? cleanTrackName(f.name),
sub: `${album.artist} — ${album.title}`,
artist: album.artist,
album: album.title ?? null,
duration: f.duration ?? ct?.duration_secs ?? null,
art: null,
artUrl: album.cover ?? null,
trackId: ct?.id ?? null,
stream: { username: folder.username, size: f.size },
quality: qualityOf(f),
};
});
player.catalogRef = { albumId: album.id ?? null, trackId: null };
playQueueIndex(0);
if (await waitForStreamStart(15000)) return;
}
stopPlayback();
resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`;
}
/** Find the best folder for an album and download all of it. */
export async function saveCatalogAlbum(album) {
if (!requireOnline() || resolver.active) return false;
// Same authoritative-count logic as streamCatalogAlbum: a single downloads
// as one track (folder-count matching can't find a lone-file release), and
// the real count keeps a multi-track Save from being rejected or grabbing a
// wrong-length edition.
const catalogTracks = album.id
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
: null;
const trackCount = catalogTracks?.length || album.track_count || null;
if (trackCount === 1) {
const t = catalogTracks?.[0];
return await saveSong(album.artist, t?.title ?? album.title);
}
const { responses, artistIncluded } = await resolve(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
(rs) => albumCandidates(rs, album, false, trackCount).length,
album.artist,
);
const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0];
if (!best) {
resolver.error = `No album sources found for “${album.title}”`;
return false;
}
// The whole folder, extras included — the pipeline organizes and the
// staging cleanup files art/logs next to the finished album.
for (const f of best.files) {
await api.downloadFile(best.username, f.path, f.size);
}
return true;
}
export const search = $state({
token: null,
query: "",
searching: false,
responses: 0,
// key: `${username}|${folder}` -> group summary (scalars only — the file
// lists live outside reactive state, see searchFiles below).
groups: {},
});
// Raw per-group file lists. Deliberately NOT reactive: a broad search stores
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what
// used to freeze the webview (GC storm at ~1GB+). Components re-render off
// the group's reactive fileCount and read the list through filesForGroup().
const searchFiles = new Map();
let searchStopTimer = null;
let searchRequest = 0;
function trimHiddenSearchFiles() {
const visibleKeys = new Set(
Object.values(search.groups)
.sort(
(a, b) =>
(b.hasFreeSlot - a.hasFreeSlot)
|| (b.speed - a.speed),
)
.slice(0, 120)
.map((group) => group.key),
);
for (const key of searchFiles.keys()) {
if (!visibleKeys.has(key)) searchFiles.delete(key);
}
}
export function filesForGroup(key) {
return searchFiles.get(key)?.files ?? [];
}
export const AUDIO_EXTS = new Set([
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
]);
export const downloads = $state({
// id -> download payload (latest update wins)
items: {},
});
export const network = $state({
shareFolders: 0,
shareFiles: 0,
respondedCount: 0,
uploads: {}, // id -> upload payload
});
const TERMINAL_TRANSFER_STATES = new Set(["complete", "failed", "cancelled"]);
const DOWNLOAD_HISTORY_MAX = 300;
const UPLOAD_HISTORY_MAX = 100;
function trimTransferHistory(store, maxTerminal, waitUntilIdle = false) {
const values = Object.values(store);
if (
waitUntilIdle
&& values.some((transfer) => !TERMINAL_TRANSFER_STATES.has(transfer.state))
) {
return;
}
const terminal = values
.filter((transfer) => TERMINAL_TRANSFER_STATES.has(transfer.state))
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
for (const transfer of terminal.slice(maxTerminal)) delete store[transfer.id];
}
export const player = $state({
status: "idle", // idle | buffering | playing | paused
track: null, // { title, sub, source: 'stream'|'local' }
position: 0,
duration: null,
// The player bar temporarily owns position while the user is dragging the
// playhead. Incoming backend ticks resume on release.
scrubbing: false,
volume: 0.7,
muted: false,
// streaming buffer info
streamReceived: 0,
streamTotal: 0,
error: "",
// album play queue (local tracks)
queue: [],
queueIndex: -1,
shuffle: false,
repeat: "off", // off | all | one
// waveform peaks (0..1) for the current local track, [] while loading
waveform: [],
// Which catalog entity is playing (drives now-playing badges).
catalogRef: { albumId: null, trackId: null },
});
// Adaptive accent theme, derived from the playing track's album art.
// Idle default matches the SoulStream brand blue (see icons/).
const DEFAULT_ACCENT = "#1f9ad6";
const DEFAULT_ACCENT_HOVER = "#41b7ee";
export const theme = $state({
accent: DEFAULT_ACCENT,
accentHover: DEFAULT_ACCENT_HOVER,
});
let accentRaf = null;
/** Animate --accent/--accent-hover to the target colors (ease-out). */
function crossfadeAccent(accent, hover, ms = 1200) {
if (accent === theme.accent && hover === theme.accentHover) return;
if (accentRaf) cancelAnimationFrame(accentRaf);
const fromA = hexToRgb(theme.accent);
const fromH = hexToRgb(theme.accentHover);
const toA = hexToRgb(accent);
const toH = hexToRgb(hover);
const t0 = performance.now();
const root = document.documentElement.style;
const step = (now) => {
if (!ui.windowActive) {
theme.accent = accent;
theme.accentHover = hover;
root.setProperty("--accent", accent);
root.setProperty("--accent-hover", hover);
accentRaf = null;
return;
}
const t = Math.min(1, (now - t0) / ms);
const e = t * (2 - t);
theme.accent = rgbToHex(lerpRgb(fromA, toA, e));
theme.accentHover = rgbToHex(lerpRgb(fromH, toH, e));
root.setProperty("--accent", theme.accent);
root.setProperty("--accent-hover", theme.accentHover);
accentRaf = t < 1 ? requestAnimationFrame(step) : null;
};
accentRaf = requestAnimationFrame(step);
}
let accentReq = 0;
/** Set the app accent from an album-art path (crossfades; null → default). */
async function setAccentFromArt(artPath) {
const req = ++accentReq;
if (!artPath) {
crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
return;
}
try {
const dataUrl = await getArt(artPath);
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
if (req !== accentReq) return; // track changed while extracting
if (colors) crossfadeAccent(colors.accent, colors.hover);
else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
} catch {
if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
}
}
/** Same, from a remote catalog cover URL (proxied to a data URL). */
async function setAccentFromArtUrl(url) {
const req = ++accentReq;
if (!url) {
crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
return;
}
try {
const dataUrl = await getCatalogArt(url);
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
if (req !== accentReq) return;
if (colors) crossfadeAccent(colors.accent, colors.hover);
else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
} catch {
if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
}
}
// ---- play history ----
// Listened time is accumulated from the player's position ticks — wall-clock
// timers would count paused/buffering time, and ticks stop exactly when audio
// does. A session row is only created once a track has actually accumulated
// listened time, so instant skips never write history. Seeks are ignored via
// the delta guard (a jump or rewind isn't listening). The playId promise
// serializes the insert and its top-ups, so rapid track changes can't lose
// the remainder or write it to the wrong row.
const LISTEN_FLUSH_SECS = 15;
let listenSession = null; // { key, pending, playIdP }
let listenLastPos = null;
function listenFlush(session = listenSession) {
if (!session?.playIdP) return;
const secs = Math.floor(session.pending);
if (secs < 1) return;
session.pending -= secs;
session.playIdP
.then((id) => api.addPlaySeconds(id, secs))
.catch(() => {});
}
function listenTick(positionSecs) {
const t = player.track;
const title = t?.title ?? "";
const artist = (t?.artist ?? "").trim()
|| String(t?.sub ?? "").split(" — ")[0].trim();
if (!title || !artist || artist.includes("…")) return;
const key = `${artist}\u0000${title}`;
if (listenSession?.key !== key) {
listenFlush();
listenSession = { key, artist, title, pending: 0, playIdP: null };
listenLastPos = null;
}
const delta = listenLastPos == null ? 0 : positionSecs - listenLastPos;
listenLastPos = positionSecs;
if (delta <= 0 || delta > 3) return;
listenSession.pending += delta;
if (!listenSession.playIdP && listenSession.pending >= 1) {
listenSession.playIdP = api.recordPlay(artist, title);
listenSession.playIdP.catch(() => {});
}
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
}
let started = false;
let windowActivityStarted = false;
let latestPlayerPosition = 0;
let latestPlayerDuration = null;
let latestStreamReceived = 0;
let latestStreamTotal = 0;
function initWindowActivity() {
if (windowActivityStarted) return;
windowActivityStarted = true;
let active = true;
let syncGeneration = 0;
let syncTimer = null;
const nativeUnlisteners = [];
const sync = async () => {
const generation = ++syncGeneration;
let minimized = false;
try {
minimized = await api.win.isMinimized();
} catch {
// Plain-browser previews have no native window API.
}
if (!active || generation !== syncGeneration) return;
const wasActive = ui.windowActive;
// A visible, unfocused window can still be watched side-by-side. Only
// actual native minimization / DOM hiding should freeze visual state.
ui.windowActive = !document.hidden && !minimized;
if (!wasActive && ui.windowActive) {
if (!player.scrubbing) player.position = latestPlayerPosition;
if (latestPlayerDuration != null) player.duration = latestPlayerDuration;
player.streamReceived = latestStreamReceived;
player.streamTotal = latestStreamTotal;
const current = player.queue[player.queueIndex];
if (
current?.stream
&& latestStreamReceived > 0
&& latestStreamReceived < latestStreamTotal
) {
waveRefreshAt = performance.now();
void loadStreamWaveform(current);
}
}
};
const scheduleSync = () => {
clearTimeout(syncTimer);
syncTimer = setTimeout(sync, 50);
};
void sync();
const addNativeListener = (promise) => {
void promise
.then((unlisten) => {
if (active) nativeUnlisteners.push(unlisten);
else unlisten();
})
.catch(() => {});
};
addNativeListener(api.win.onResized(scheduleSync));
addNativeListener(api.win.onFocusChanged(scheduleSync));
document.addEventListener("visibilitychange", scheduleSync);
if (import.meta.hot) {
import.meta.hot.dispose(() => {
active = false;
clearTimeout(syncTimer);
document.removeEventListener("visibilitychange", scheduleSync);
for (const unlisten of nativeUnlisteners) unlisten();
});
}
}
/** Wire backend events once, at app start. */
export function initEvents() {
if (started) return;
started = true;
initWindowActivity();
// Preview audio-focus bridge (scrollable-track-previews). Injected here
// rather than imported there: this module owns the player store, and the
// preview module must stay import-cycle-free. resumeMain goes straight to
// api — NOT through any wrapper that bumps the revision gateway, because
// a focus-internal resume must not invalidate its own token.
initPreviewBridge({
readPlayer: () => player,
resumeMain: () => {
// Close crossfade: come back from the preview at volume 0 and
// ramp to the user's level while the preview rings down.
api.playerFadeResume(PREVIEW_CROSSFADE_MS);
},
});
// The `connected` event fires once, at login. A WebView reload (dev
// iteration, crash recovery) lands AFTER it, so the fresh frontend
// believed it was offline against a logged-in backend and pushed the
// login modal at the first play attempt. Recover the real state at boot —
// retried, because the first invoke can race IPC readiness at mount.
void (async () => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const status = await api.appStatus();
if (status?.connected_as && app.connection !== "online") {
app.connection = "online";
app.username = status.connected_as;
app.showLogin = false;
}
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 2500));
}
}
})();
api.onEvent("soulseek:state", (ev) => {
if (ev.kind === "connected") {
app.connection = "online";
app.username = ev.username;
app.loginError = "";
} else if (ev.kind === "disconnected") {
app.connection = "offline";
}
});
api.onEvent("soulseek:excluded-phrases", setExcludedSearchPhrases);
api.onEvent("soulseek:shares", (s) => {
network.shareFolders = s.folders;
network.shareFiles = s.files;
});
api.onEvent("soulseek:upload", (u) => {
network.uploads[u.id] = u;
if (TERMINAL_TRANSFER_STATES.has(u.state)) {
trimTransferHistory(network.uploads, UPLOAD_HISTORY_MAX);
}
});
api.onEvent("soulseek:responded", () => {
network.respondedCount += 1;
});
api.onEvent("soulseek:download", (d) => {
// Preserve last known progress on terminal states that carry none.
const prev = downloads.items[d.id];
if (prev && d.total === 0) {
d.received = prev.received;
d.total = prev.total;
}
downloads.items[d.id] = d;
if (TERMINAL_TRANSFER_STATES.has(d.state)) {
// Preserve every member of an active multi-file burst so the
// title-bar aggregate remains monotonic. Prune once it drains.
trimTransferHistory(downloads.items, DOWNLOAD_HISTORY_MAX, true);
}
});
api.onEvent("soulseek:player", (ev) => {
// Revision-gateway bumps at the ONE choke point every native source
// change flows through. Each new decode ("buffering") is a new
// playback generation for the focus bridge; track-ended is the
// auto-advance revision. A pause caused by the focus coordinator
// emits "paused", which deliberately bumps nothing.
if (ev.kind === "buffering") {
notePlaybackGeneration();
bumpMainRevision("nativeSourceChange");
} else if (ev.kind === "track-ended") {
bumpMainRevision("autoAdvance");
} else if (ev.kind === "stopped") {
bumpMainRevision("userStop");
}
switch (ev.kind) {
case "buffering":
player.status = "buffering";
player.position = 0;
latestPlayerPosition = 0;
latestPlayerDuration = player.duration;
latestStreamReceived = 0;
latestStreamTotal = 0;
player.scrubbing = false;
player.error = "";
break;
case "playing":
player.status = "playing";
break;
case "paused":
player.status = "paused";
listenFlush();
break;
case "stopped":
player.scrubbing = false;
listenFlush();
stopPlayback();
break;
case "track-ended":
listenFlush();
if (ev.gapless) advanceGaplessly();
else nextTrack(true);
break;
case "position":
latestPlayerPosition = ev.secs;
if (ev.duration != null) latestPlayerDuration = ev.duration;
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
if (ev.duration != null && player.duration !== ev.duration) {
player.duration = ev.duration;
}
listenTick(ev.secs);
break;
case "error":
player.status = "idle";
player.error = ev.message;
break;
}
});
api.onEvent("soulseek:stream", (s) => {
latestStreamReceived = s.received;
latestStreamTotal = s.total;
if (ui.windowActive) {
player.streamReceived = s.received;
player.streamTotal = s.total;
}
const cur = player.queue[player.queueIndex];
if (s.done) {
// Fully cached → final waveform, and the peer's slot is
// definitely free, so make sure the next track is pre-buffering.
if (cur?.stream) loadStreamWaveform(cur, true);
preloadNextStream();
tryAppendNext();
} else if (
ui.windowActive
&& cur?.stream
&& performance.now() - waveRefreshAt > 3000
) {
// Still downloading: refresh the partial waveform so it grows
// with the transfer.
waveRefreshAt = performance.now();
loadStreamWaveform(cur);
}
});
// A next-track pre-buffer finished downloading: append it to the audio
// sink so the upcoming transition is gapless.
api.onEvent("soulseek:preload", (p) => {
if (p.done) tryAppendNext();
});
let libraryRefreshTimer = null;
api.onEvent("soulseek:library-changed", () => {
clearTimeout(libraryRefreshTimer);
libraryRefreshTimer = setTimeout(refreshLibrary, 400);
});
api.onEvent("soulseek:playlists-changed", refreshPlaylists);
// The backend batches search responses (~150ms ticks) so a broad search
// can't flood the IPC bridge or trigger a reactive flush per response.
api.onEvent("soulseek:search-results", (batch) => {
let matched = 0;
for (const r of batch) {
if (!search.searching || r.token !== search.token) continue;
matched += 1;
for (const f of r.files) {
const key = `${r.username}|${f.folder}`;
let g = search.groups[key];
let raw = searchFiles.get(key);
if (!g) {
// Cap group count so a popular search can't flood the DOM.
if (searchFiles.size >= 300) continue;
raw = { files: [], exts: new Map() };
searchFiles.set(key, raw);
g = search.groups[key] = {
key,
username: r.username,
folder: f.folder,
folderName: f.folder.split("\\").pop() || r.username,
hasFreeSlot: r.has_free_upload_slot,
speed: r.upload_speed,
queue: r.queue_length,
// Scalar summaries the UI renders; the file list
// itself stays out of reactive state.
fileCount: 0,
audioCount: 0,
totalSize: 0,
topExt: "",
maxBitrate: 0,
};
} else if (!raw) {
// Defensive recovery if a late batch races raw-list
// pruning. Keep the existing scalar summary intact.
raw = { files: [], exts: new Map() };
searchFiles.set(key, raw);
}
if (raw.files.length < 500) {
raw.files.push(f);
g.fileCount += 1;
g.totalSize += f.size;
if (AUDIO_EXTS.has(f.extension)) g.audioCount += 1;
if (f.bitrate && f.bitrate > g.maxBitrate) g.maxBitrate = f.bitrate;
const n = (raw.exts.get(f.extension) ?? 0) + 1;
raw.exts.set(f.extension, n);
if (n >= (raw.exts.get(g.topExt) ?? 0)) g.topExt = f.extension;
}
}
}
if (matched > 0) search.responses += matched;
});
}
export async function connect(username, password, remember) {
app.connection = "connecting";
app.loginError = "";
try {
await api.connectAndLogin(username, password, remember);
app.connection = "online";
app.username = username;
app.showLogin = false;
} catch (e) {
app.connection = "offline";
app.loginError = String(e);
}
}
export async function autoConnect() {
// A reloaded WebView boots against a backend that may ALREADY be logged
// in; connect_stored then fails ("already connected") and the catch below
// used to stamp the page offline — the first play attempt raised the
// login modal against a healthy session. Adopt the live session instead.
try {
const status = await api.appStatus();
if (status?.connected_as) {
app.connection = "online";
app.username = status.connected_as;
app.showLogin = false;
return;
}
} catch {
// status unavailable — fall through to the normal connect path
}
try {
const creds = await api.getCredentials();
if (!creds.has_password) return;
app.connection = "connecting";
await api.connectStored();
app.connection = "online";
app.username = creds.username;
} catch {
// One more status probe before declaring offline: the connect may
// have lost a race with an existing session rather than failed.
try {
const status = await api.appStatus();
if (status?.connected_as) {
app.connection = "online";
app.username = status.connected_as;
return;
}
} catch {}
app.connection = "offline";
}
}
export async function downloadFile(username, file) {
await api.downloadFile(username, file.path, file.size);
}
/** Stream a search-result file: play while it downloads to cache. */
export async function streamFile(username, file, subLabel) {
autoAlbum = null;
player.queue = [
{
path: file.path,
title: file.name.replace(/\.[^.]+$/, ""),
sub: subLabel ?? username,
duration: file.duration ?? null,
art: null,
artUrl: null,
stream: { username, size: file.size },
quality: qualityOf(file),
},
];
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(0);
}
/** Play a finished download from disk (single track). */
export async function playLocalFile(path, title, subLabel, artPath = null) {
bumpMainRevision("userPlay");
autoAlbum = null;
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
player.queueIndex = 0;
player.track = { title, sub: subLabel ?? "", source: "local", path };
player.status = "buffering";
player.duration = null;
player.streamTotal = 0;
loadWaveform(path);
setAccentFromArt(artPath);
await api.playLocal(path, null);
}
/** Play an album/playlist (list of library tracks) starting at `start`.
* `artPath` is the album cover; playlist tracks carry their own art_path. */
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
autoAlbum = null;
player.queue = tracks.map((t) => ({
path: t.path,
title: t.title,
sub: subLabel,
artist: t.artist ?? null,
duration: t.duration_secs ?? null,
art: t.art_path ?? artPath,
quality: localQuality(t),
}));
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start);
}
function playQueueIndex(i) {
const item = player.queue[i];
if (!item) return;
// Every queue start is a user-command + queue + playback revision for the
// preview focus gateway (auto-advance re-enters here too — that bump is
// correct: a restore must never resurrect a track the queue moved past).
bumpMainRevision("queueJumpOrNextPrev");
player.queueIndex = i;
player.track = {
title: item.title,
sub: item.sub,
// Explicit artist when the queue builder knew it; the play-history
// tracker falls back to parsing "artist — album" out of sub.
artist: item.artist ?? null,
source: item.stream ? "stream" : "local",
path: item.path,
};
player.status = "buffering";
player.duration = item.duration;
if (item.stream) {
// Remote track streamed from a peer while it downloads to cache.
player.streamReceived = 0;
player.streamTotal = item.stream.size;
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
appendedNextPath = null;
setAccentFromArtUrl(item.artUrl ?? null);
loadStreamWaveform(item); // instant when already fully cached
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
.then(() => {
// Ordered behind play_stream so the gapless append can't
// race the generation bump on the audio thread.
preloadNextStream();
tryAppendNext();
})
.catch((e) => {
player.error = String(e);
});
maybeExtendAlbumQueue();
return;
}
player.streamTotal = 0;
loadWaveform(item.path);
setAccentFromArt(item.art ?? null);
api.playLocal(item.path, item.duration).catch((e) => {
player.error = String(e);
});
preloadFollowingTrack();
}
/** Play a specific queue position (Now Playing panel track clicks). */
export function jumpToQueueIndex(i) {
if (i < 0 || i >= player.queue.length || i === player.queueIndex) return;
player.position = 0;
playQueueIndex(i);
}
function gaplessNextIndex() {
if (player.shuffle) return -1;
if (player.repeat === "one") return player.queueIndex;
const next = player.queueIndex + 1;
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
}
function preloadFollowingTrack() {
const next = gaplessNextIndex();
if (next < 0) return;
const item = player.queue[next];
if (item.stream) return; // gapless preload is local-only
api.preloadLocal(item.path, item.duration).catch(() => {});
}
// Quietly pre-buffer the next queued stream into the cache. The backend
// no-ops when it's already cached or being fetched; single-slot peers just
// queue the request until the current transfer finishes.
let lastPreloadedPath = null;
function preloadNextStream() {
const next = gaplessNextIndex();
const item = next >= 0 ? player.queue[next] : null;
if (!item?.stream || item.path === lastPreloadedPath) return;
lastPreloadedPath = item.path;
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {});
}
// Once the next queued stream is fully cached, append it to the active
// audio sink so the boundary is gapless (same mechanism as local albums).
// Keyed per target path so it fires once per upcoming transition.
let appendedNextPath = null;
async function tryAppendNext() {
const next = gaplessNextIndex();
const item = next >= 0 ? player.queue[next] : null;
if (!item?.stream || item.path === appendedNextPath) return;
if (player.status === "idle") return;
try {
const ok = await api.preloadCachedStream(
item.stream.username,
item.path,
item.stream.size,
item.duration ?? null,
);
if (ok) appendedNextPath = item.path;
} catch {
// cache incomplete or command unavailable — the normal (non-gapless)
// advance still works.
}
}
function advanceGaplessly() {
const next = gaplessNextIndex();
// The backend only labels a boundary gapless when it has this source
// ready in the same sink. If state changed meanwhile, fall back safely.
if (next < 0) return nextTrack(true);
const item = player.queue[next];
player.queueIndex = next;
player.position = 0;
player.duration = item.duration;
latestPlayerPosition = 0;
latestPlayerDuration = item.duration ?? null;
latestStreamReceived = 0;
latestStreamTotal = 0;
player.status = "playing";
appendedNextPath = null;
if (item.stream) {
// A pre-buffered stream track, playing from its completed cache
// file — no transfer to report.
player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path };
player.streamReceived = 0;
player.streamTotal = 0;
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
setAccentFromArtUrl(item.artUrl ?? null);
loadStreamWaveform(item);
preloadNextStream();
tryAppendNext();
maybeExtendAlbumQueue();
return;
}
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
loadWaveform(item.path);
setAccentFromArt(item.art ?? null);
preloadFollowingTrack();
}
// ---- album continuation ----
// A single catalog song remembers its album: when it starts playing, the
// album's next track is resolved (background Soulseek search) and appended
// to the queue, one track per track start, so the album keeps going.
let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending }
async function maybeExtendAlbumQueue() {
const ctx = autoAlbum;
if (!ctx || ctx.pending) return;
if (player.queueIndex !== player.queue.length - 1) return; // next already queued
const cur = player.queue[player.queueIndex];
if (!cur?.stream) return;
ctx.pending = true;
try {
if (!ctx.tracks) {
const page = await api.catalogAlbum(ctx.albumId);
if (autoAlbum !== ctx) return;
ctx.tracks = page.tracks;
ctx.albumTitle ??= page.album.title ?? null;
ctx.cover ??= page.album.cover ?? null;
}
let idx = ctx.tracks.findIndex((t) => t.id === cur.trackId);
if (idx < 0) idx = ctx.tracks.findIndex((t) => normText(t.title) === normText(cur.title));
if (idx < 0) {
autoAlbum = null;
return;
}
for (let i = idx + 1; i < ctx.tracks.length; i++) {
const t = ctx.tracks[i];
if (app.connection !== "online") return;
// Same sanitized ladder as resolve(), inlined because this is a
// background append that must not toggle resolver.active (that
// would block user-triggered resolutions while it searches).
const goodCount = (rs) =>
songCandidates(rs, t.title, true, ctx.albumTitle, t.duration_secs, t.artist).length;
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
let artistIncluded = true;
let responses = primary ? await collectSources(primary, 7000, goodCount) : [];
if (!responses.length && fallback && fallback !== primary) {
responses = await collectSources(fallback, 7000, goodCount);
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
} else if (primary) {
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
}
// Bail if the user moved on while we were searching.
if (autoAlbum !== ctx || player.queue[player.queueIndex] !== cur) return;
const best = songCandidates(
responses, t.title, true, ctx.albumTitle, t.duration_secs, t.artist, !artistIncluded,
)[0];
if (!best) continue; // no streamable source — skip this track
player.queue.push({
path: best.file.path,
title: t.title,
sub: t.artist,
artist: t.artist ?? null,
album: ctx.albumTitle ?? null,
duration: best.file.duration ?? t.duration_secs ?? null,
art: null,
artUrl: ctx.cover,
trackId: t.id,
stream: { username: best.username, size: best.file.size },
quality: qualityOf(best.file),
});
preloadNextStream();
tryAppendNext();
return;
}
autoAlbum = null; // album finished
} catch {
// transient (offline, catalog hiccup) — retried on the next track start
} finally {
ctx.pending = false;
}
}
// ---- waveform ----
const WAVE_BUCKETS = 400;
let waveReqPath = null;
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
let waveRefreshAt = 0;
async function loadWaveform(path) {
player.waveform = [];
waveReqPath = path;
if (!path) return;
try {
const peaks = await api.waveform(path, WAVE_BUCKETS);
// Ignore if the track changed while we were computing.
if (waveReqPath === path) player.waveform = peaks;
} catch {
if (waveReqPath === path) player.waveform = [];
}
}
/** Waveform for a streamed queue item, computed from its cache file. Empty
* (plain progress bar) until the download completes; the stream-done event
* calls this again to swap the full waveform in mid-track. */
let waveSeq = 0;
let streamWaveInFlight = false;
let queuedStreamWave = null;
async function loadStreamWaveform(item, final = false) {
const key = `stream:${item.stream.username}|${item.path}`;
if (waveReqPath !== key) player.waveform = [];
waveReqPath = key;
// A late partial FLAC decode can take close to the three-second refresh
// interval. Coalesce all overlap into the newest request, retaining a
// final completion request so the full-quality waveform always wins.
if (streamWaveInFlight) {
const preserveFinal = queuedStreamWave?.key === key && queuedStreamWave.final;
queuedStreamWave = { item, key, final: final || preserveFinal };
return;
}
streamWaveInFlight = true;
let next = { item, key, final };
try {
while (next) {
const request = next;
next = null;
const seq = ++waveSeq;
try {
const peaks = await api.streamWaveform(
request.item.stream.username,
request.item.path,
request.item.stream.size,
WAVE_BUCKETS,
);
if (
waveReqPath === request.key
&& seq === waveSeq
&& peaks.length > 0
) {
player.waveform = peaks;
}
} catch {
// stays empty — the buffer/progress bar renders instead
}
const queued = queuedStreamWave;
queuedStreamWave = null;
// Only the newest still-visible track matters. A queued final call
// is never dropped; the backend independently confirms completion.
if (queued && waveReqPath === queued.key) next = queued;
}
} finally {
streamWaveInFlight = false;
}
}
// ---- queue navigation, shuffle, repeat ----
// Indices already played this shuffle cycle (module-private).
let shuffleSeen = new Set();
function pickShuffleIndex(reset = false) {
const n = player.queue.length;
if (reset) shuffleSeen = new Set();
const remaining = [];
for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i);
if (remaining.length === 0) return -1;
return remaining[Math.floor(Math.random() * remaining.length)];
}
export function nextTrack(auto = false) {
const n = player.queue.length;
if (n === 0) {
stopPlayback();
return;
}
// Auto-advance on a repeat-one track replays it.
if (auto && player.repeat === "one") {
playQueueIndex(player.queueIndex);
return;
}
let next;
if (player.shuffle) {
shuffleSeen.add(player.queueIndex);
next = pickShuffleIndex();
if (next < 0) {
if (player.repeat === "all") next = pickShuffleIndex(true);
else return stopPlayback();
}
} else {
next = player.queueIndex + 1;
if (next >= n) {
if (player.repeat === "all") next = 0;
else return stopPlayback();
}
}
playQueueIndex(next);
}
export function prevTrack() {
// Restart the current track if we're more than 3s in.
if (player.position > 3) {
api.playerControl("seek", 0);
return;
}
const prev = player.queueIndex - 1;
if (prev >= 0) playQueueIndex(prev);
else api.playerControl("seek", 0);
}
export function toggleShuffle() {
player.shuffle = !player.shuffle;
shuffleSeen = new Set([player.queueIndex]);
}
export function cycleRepeat() {
player.repeat =
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
}
function stopPlayback() {
player.status = "idle";
player.track = null;
player.position = 0;
player.duration = null;
player.queue = [];
player.queueIndex = -1;
player.waveform = [];
player.catalogRef = { albumId: null, trackId: null };
latestPlayerPosition = 0;
latestPlayerDuration = null;
latestStreamReceived = 0;
latestStreamTotal = 0;
autoAlbum = null;
appendedNextPath = null;
lastPreloadedPath = null;
setAccentFromArt(null);
}
export async function downloadFolder(group) {
for (const f of filesForGroup(group.key)) {
await api.downloadFile(group.username, f.path, f.size);
}
}
// Dev-only handle to THIS module instance for CDP-driven verification —
// a dynamic import from the console can resolve to a second instance when
// Vite has HMR-stamped the app's own import URL.
if (import.meta.env.DEV && typeof window !== "undefined") {
window.__state = {
app, player, resolver, catalog, ui, discovery, downloads, libraryStore,
streamSong, streamCatalogAlbum, autoConnect, nextTrack, prevTrack, loadDiscovery,
saveStream, refreshLibrary, openCatalogArtist, openCatalogAlbum,
playAlbum, jumpToQueueIndex, toggleNowPlaying,
artBroken, markArtBroken,
coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum,
};
}
export async function runSearch(query) {
const request = ++searchRequest;
clearTimeout(searchStopTimer);
const previousToken = search.token;
// Invalidate routing before awaiting the new backend token, otherwise
// late batches from the previous search can populate the freshly-cleared
// result set during this gap.
search.token = null;
if (previousToken != null) {
api.stopSearch(previousToken).catch(() => {});
}
search.query = query;
search.groups = {};
searchFiles.clear();
search.responses = 0;
search.searching = true;
const token = await api.startSearch(query);
if (request !== searchRequest) {
api.stopSearch(token).catch(() => {});
return;
}
search.token = token;
// Soulseek searches trickle in; stop routing after 45s.
searchStopTimer = setTimeout(() => {
if (search.token === token) {
search.searching = false;
// The UI exposes only the top 120 groups. Keep their full file
// lists and discard invisible raw payloads after routing closes;
// scalar summaries remain available for the result count.
trimHiddenSearchFiles();
api.stopSearch(token).catch(() => {});
}
}, 45_000);
}
// Dev-only: expose the LIVE module instance for the CDP verification loop.
// Vite's HMR graph can serve a probe's bare-URL dynamic import as a SECOND
// instance of this module (?t=-stamped vs bare), so a probe that imports by
// URL may read/mutate a phantom copy while the app runs another. Probes must
// use this global instead of importing.
if (import.meta.env.DEV) {
globalThis.__soulstream = {
app, ui, player, catalog, resolver,
streamCatalogAlbum, streamSong, openCatalogArtist,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
};
}