From 1737c5e1b6200581d7f3baefef33c0d3ebcf31f7 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:46:07 -0500 Subject: [PATCH] =?UTF-8?q?android:=20the=20catalog's=20numbers=20?= =?UTF-8?q?=E2=80=94=20Popular's=20own=20lane,=20a=20count=20per=20row,=20?= =?UTF-8?q?the=20artist's=20photo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popular is asked on its own as well as with the page, so the section can paint before the full page is built and an artist whose page came back with none still gets one. A catalog record's rows that the album's own artist anchor found no count for — a feature's guest, a compilation's stranger — get the deferred Last.fm pass by their own artist id. The artist page's ⋯ is its own sheet now: choose a hero photo from this phone, or put the catalog's own back; set_artist_hero is pick_artist_hero's install step split out and shared, so the desktop's dialog keeps its door. You gains the desktop's Refresh artist portraits, and a data: URL is the image itself in the art gate — a chosen hero comes back from Rust as one, and the thumbnail fetch used to throw it away. --- ANDROID.md | 20 +++++++- src-tauri/src/commands.rs | 29 +++++++++-- src-tauri/src/lib.rs | 1 + src/lib/api.js | 3 ++ src/lib/mockLibrary.js | 33 +++++++++++- src/mobile/HalfSheet.svelte | 22 +++++++- src/mobile/art.svelte.js | 3 ++ src/mobile/catalog.svelte.js | 70 ++++++++++++++++++++++++++ src/mobile/screens/Artist.svelte | 12 ++++- src/mobile/screens/CatalogAlbum.svelte | 13 +++-- src/mobile/screens/You.svelte | 13 +++++ tests/mobile/smoke.mjs | 26 +++++++++- 12 files changed, 232 insertions(+), 13 deletions(-) diff --git a/ANDROID.md b/ANDROID.md index c73912e..6c9e696 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -271,7 +271,25 @@ empty Search page shows Jump back in, the desktop's. The Library's cards wear th one call for the whole library: FLAC 24/96, MP3 320k, the size on the list rows and the record page), and the artist page shows this phone's own listening (`artist_play_stats`: time played as a chip, the most -played track under "On this phone"). Search corrects an obvious +played track under "On this phone"). Popular is asked on its own as well as +with the page (`catalog_artist_popular`: the Last.fm ranking and its counts, +waiting on nothing but the linked MBID, so the section can paint before the +full page is built — and an artist whose page came back with no Popular at +all still gets one), and a catalog record's rows that the album's own artist +anchor found no count for — a feature's guest, a compilation's stranger — +get the desktop's deferred pass by their OWN Deezer artist id +(`catalog_track_listeners`). An artist's photo is Kyle's to choose: the +artist page's ⋯ offers **Choose hero photo** (the phone's image picker, then +`set_artist_hero` — `pick_artist_hero`'s install step split out and shared, +so the desktop's dialog keeps its own door; 1920×800 and landscape or it is +refused) and **Remove custom hero**, which puts the catalog's own photos +back; the page is asked again either way, so the banner, the ground and the +accent all follow. The desktop's own chooser has had no home since its pill +left the glass bar — the phone has one first. You → Album artwork carries +**Refresh artist portraits** (`refresh_artist_art`), the desktop Settings +row. A `data:` URL is the image itself in `art.svelte.js` now — a chosen +hero comes back from Rust as one, and the thumbnail fetch used to throw it +away. Search corrects an obvious misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule, a word only when a real name is within two edits. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8c40ce9..b1470fd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2017,10 +2017,21 @@ pub async fn pick_artist_hero( let Some(source) = rx.await.unwrap_or(None) else { return Ok(None); }; + install_artist_hero(&app, artist_id, source).map(Some) +} + +/// A picked image — the desktop's dialog, or a photo the phone's storage +/// plugin already copied into the app's cache — validated and written as +/// this artist's chosen hero. +fn install_artist_hero( + app: &tauri::AppHandle, + artist_id: u64, + source: String, +) -> Result { let source = std::path::PathBuf::from(source); let (width, height) = validate_artist_hero(&source)?; let (ext, _) = artist_image_format(&source)?; - let dir = artist_hero_dir(&app)?; + let dir = artist_hero_dir(app)?; let temporary = dir.join(format!("{artist_id}.importing")); std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?; for old_ext in ["jpg", "png", "webp"] { @@ -2031,11 +2042,23 @@ pub async fn pick_artist_hero( let _ = std::fs::remove_file(&temporary); return Err(error.to_string()); } - let mut hero = load_artist_hero_override(&app, artist_id)? + let mut hero = load_artist_hero_override(app, artist_id)? .ok_or_else(|| "The imported hero could not be reopened".to_string())?; hero.width = width; hero.height = height; - Ok(Some(hero)) + Ok(hero) +} + +/// The phone's door onto the same import: a photo already on disk (the +/// storage plugin's picker copied it into the app's cache) becomes this +/// artist's chosen hero. The dialog above stays the desktop's. +#[tauri::command] +pub async fn set_artist_hero( + app: tauri::AppHandle, + artist_id: u64, + path: String, +) -> Result { + install_artist_hero(&app, artist_id, path) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 663e569..f21c577 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -325,6 +325,7 @@ pub fn run() { commands::catalog_artist_overview, commands::catalog_artist_popular, commands::pick_artist_hero, + commands::set_artist_hero, commands::clear_artist_hero, commands::catalog_related, commands::catalog_track_listeners, diff --git a/src/lib/api.js b/src/lib/api.js index 21e10c4..51a4797 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -403,6 +403,9 @@ export const catalogArtistPopular = (artistId) => invoke("catalog_artist_popular", { artistId }); export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId }); export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId }); +// The phone's hero door: a photo the storage plugin already copied into the +// app's cache, installed the way the desktop's dialog pick installs one. +export const setArtistHero = (artistId, path) => invoke("set_artist_hero", { artistId, path }); export const catalogRelated = (artistId) => invoke("catalog_related", { artistId }); // Deferred Last.fm pass for search results: `tracks` is [{ artistId, title }] // and the answer comes back in the same order. Kept OUT of catalog_search, diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 17a0db4..f5c78f8 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -370,6 +370,9 @@ export function mockInvoke(cmd, args) { if (cmd === "album_tracks") return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60)); if (cmd === "album_art") { + /* not a path: the real one fails to open it, which is why the art + gate has to answer a data: URL itself */ + if (String(args.path || "").startsWith("data:")) return Promise.reject(new Error("album_art: not a path")); const m = /(\d+)$/.exec(args.path || ""); const num = m ? +m[1] : 0; return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26)))); @@ -494,6 +497,8 @@ const NEWS = [ { title: "Infinity Repeating gets a video", url: "https://example.com/news/2", source: "NME", date: Math.floor(Date.now() / 1000) - 86400 * 2 }, ]; const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 })); +/* the heroes chosen this session — pick_image → set_artist_hero */ +const heroes = {}; let follows = []; function mockCatalog(cmd, args) { if (cmd === "catalog_artist") { @@ -502,7 +507,7 @@ function mockCatalog(cmd, args) { if (!a) return Promise.reject(new Error("catalog_artist: unknown artist")); const own = a === CAT_ARTIST; return new Promise((r) => setTimeout(() => r({ - artist: a, banner: null, banners: [], banner_custom: false, logos: [], + artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [], popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [], albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }], top_genre: "Electronic", socials: [], @@ -518,6 +523,27 @@ function mockCatalog(cmd, args) { if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] }); if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement."); if (cmd === "catalog_related") return Promise.resolve(RELATED); + /* Popular's own lane: ranked, with counts — and for the artists whose + page gives none (the related ones), the only Popular there is */ + if (cmd === "catalog_artist_popular") { + const id = Number(args?.artistId); + const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id); + if (!a) return Promise.resolve([]); + const rows = id === CAT_ARTIST.id + ? CAT_TRACKS.slice(0, 7) + : TITLES.slice(0, 4).map((t, i) => ({ + id: 4200000 + id * 10 + i, title: t, artist: a.name, artist_id: id, rank: 700000 - i * 1000, + album: a.name + " Live", album_id: 900 + id, cover: null, duration_secs: 210 + i * 13, preview: null, track_no: i + 1, + })); + return new Promise((r) => setTimeout(() => r(rows.map((t, i) => ({ ...t, listeners: 1_900_000 - i * 240_000 }))), 260)); + } + /* the deferred pass: a count per row, by that row's own artist id */ + if (cmd === "catalog_track_listeners") { + const rows = args?.tracks || []; + return new Promise((r) => setTimeout(() => r(rows.map((t, i) => (t.artistId ? 640_000 - i * 20_000 : null))), 260)); + } + if (cmd === "set_artist_hero") { const id = Number(args?.artistId); heroes[id] = svgArt(180, "H"); return Promise.resolve({ data_url: heroes[id], width: 3840, height: 2160 }); } + if (cmd === "clear_artist_hero") { const id = Number(args?.artistId); const had = id in heroes; delete heroes[id]; return Promise.resolve(had); } if (cmd === "artist_play_stats") return Promise.resolve({ seconds: 7380, last_played_at: Math.floor(Date.now() / 1000) - 86400, top_title: "Track 1", top_count: 12 }); if (cmd === "followed_artists") return Promise.resolve(follows.slice()); if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); } @@ -532,7 +558,9 @@ function mockCatalog(cmd, args) { if (cmd === "catalog_album") { const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId)); if (!a) return Promise.reject(new Error("catalog_album: unknown album")); - return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false }); + /* the last two rows are a guest's: the album's own artist anchor + has no count for them, which is what the deferred pass is for */ + return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => (i >= CAT_TRACKS.length - 2 ? { ...t, artist: "Daft Punk feat. Todd Edwards", artist_id: 411 } : { ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false }); } if (cmd === "catalog_album_info") { const t = String(args?.title || ""); @@ -558,6 +586,7 @@ function mockCatalog(cmd, args) { if (cmd === "rescan_library") return Promise.resolve(); if (cmd === "library_art_prewarm") return Promise.resolve(); if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300)); + if (cmd === "refresh_artist_art") return new Promise((r) => setTimeout(() => r(26), 300)); if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D")); if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total)); if (cmd === "preload_stream") { diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index 4502ffe..c238774 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -11,7 +11,7 @@ import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js"; import { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, moveInQueue, setSleep, sleepLabel } from "./player.svelte.js"; import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; - import { catalog, yearOf } from "./catalog.svelte.js"; + import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js"; import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear } from "./playlists.svelte.js"; import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js"; import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js"; @@ -38,6 +38,7 @@ if (t.n === "library") return "library-more"; if (t.n === "playlist") return "playlist-more"; if (t.n === "catalogAlbum") return "catalogAlbum-more"; + if (t.n === "artist") return "artist-more"; return "home-more"; }); @@ -682,6 +683,25 @@
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))} + {:else if kind === "artist-more"} + {@const nm = top().c.name} + {@const ent = catalog.artists[artistKey(nm)] ?? null} + {@const pg = ent?.page ?? null} + {@const aid = pg?.artist?.id ?? ent?.id ?? null} +
+ {#if picture(pg?.artist?.picture)}{:else}{/if} + {nm}{pg?.albums?.length ? pg.albums.length + " releases on the catalog" : "Artist"} +
+
+ {#if aid != null} + {@render row("image", "Choose hero photo…", "a wide picture from this phone, 1920×800 or larger", act(() => heroFromPhoto(aid, nm).catch(oops)))} + {#if pg?.banner_custom} + {@render row("x", "Remove custom hero", "the catalog's own photos come back", act(() => heroClear(aid, nm).catch(oops)))} + {/if} +
+ {/if} + {@render row("settings", "Artwork settings", "covers and portraits, in You", act(() => go("you")))} +
{:else if kind === "home-more"}
SoulStream
diff --git a/src/mobile/art.svelte.js b/src/mobile/art.svelte.js index e265b84..096e628 100644 --- a/src/mobile/art.svelte.js +++ b/src/mobile/art.svelte.js @@ -23,6 +23,9 @@ let inFlight = 0; export function loadArt(path, urgent = false) { if (!path) return Promise.resolve(""); if (art[path] !== undefined) return Promise.resolve(art[path]); + /* a data: URL is already the image — an artist's chosen hero comes back + from Rust as one, and the thumbnail fetch would throw it away */ + if (path.startsWith("data:")) { art[path] = path; return Promise.resolve(path); } /* a streamed record's cover comes out of the file's own tags once enough of it is here (player.svelte.js sets it); nothing to fetch, and a miss must not be remembered */ diff --git a/src/mobile/catalog.svelte.js b/src/mobile/catalog.svelte.js index 4ecfcc8..3c6c499 100644 --- a/src/mobile/catalog.svelte.js +++ b/src/mobile/catalog.svelte.js @@ -7,6 +7,7 @@ exact-name matches, the most-followed wins, so a shared name resolves to the canonical artist and never to a namesake the search listed first. */ import * as api from "../lib/api.js"; +import { note } from "./notice.svelte.js"; export const catalog = $state({ /** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */ @@ -23,6 +24,11 @@ export const catalog = $state({ seeds: {}, /** "artist\u0000title" → { status, info: { description, genre, label, source } } */ albumInfo: {}, + /** artist id → { status, tracks } — Popular on its own, ahead of the page */ + popular: {}, + /** catalog album id → { status, counts: { trackId: listeners } } — the + * deferred Last.fm pass for the rows the record's page had no count for */ + listeners: {}, }); export function artistKey(name) { @@ -91,6 +97,20 @@ export function loadProfile(id) { ); } +/** Popular on its own — the desktop's fast lane (`catalog_artist_popular`). + * It waits on nothing but the linked MBID and one Last.fm call, so the + * section can paint while the full page is still being built; the page's + * own list wins once it lands (the same order, from the same inputs), and + * this one stays for an artist whose page came back with no Popular. */ +export function loadPopular(id) { + if (id == null || catalog.popular[id]) return; + catalog.popular[id] = { status: "loading", tracks: [] }; + api.catalogArtistPopular(id).then( + (tracks) => { catalog.popular[id] = { status: "ready", tracks: tracks ?? [] }; }, + () => { catalog.popular[id] = { status: "error", tracks: [] }; }, + ); +} + /** A portrait for a byline — the same identity rule, search only. */ export function seedFor(name) { const k = artistKey(name); @@ -114,6 +134,56 @@ export function loadAlbumInfo(artist, title) { ); } +/** The rows a record's own artist anchor found no count for — a feature's + * guest, a compilation's stranger — asked by each row's OWN Deezer artist + * id, the rule the desktop's search results follow. A row the backend has + * no count for keeps nothing: unknown is not a zero. */ +export function loadTrackListeners(albumId, tracks) { + if (albumId == null || catalog.listeners[albumId]) return; + const want = (tracks || []).filter((t) => t.listeners == null && t.artist_id != null); + if (!want.length) return; + catalog.listeners[albumId] = { status: "loading", counts: {} }; + api.catalogTrackListeners(want.map((t) => ({ artistId: t.artist_id, title: t.title }))).then( + (counts) => { + const out = {}; + (counts || []).forEach((n, i) => { if (n != null && want[i]) out[want[i].id] = n; }); + catalog.listeners[albumId] = { status: "ready", counts: out }; + }, + () => { catalog.listeners[albumId] = { status: "error", counts: {} }; }, + ); +} + +/** Forget an artist's page and ask for it again — the desktop's + * `invalidateCatalogArtist`, for a hero that has just changed. The seed is + * kept, so the identity search does not run a second time. */ +export function forgetArtist(name) { + const k = artistKey(name); + const cur = catalog.artists[k]; + if (!cur) return; + delete catalog.artists[k]; + lookupArtist(name, cur.seed ?? null); +} + +/** A photo from this phone as an artist's hero — the desktop's Choose hero, + * through the phone's own image picker. The page is asked again, so the + * banner, the ground and the accent all follow. */ +export async function heroFromPhoto(id, name) { + let r; + try { r = await api.pickImage(); } catch (e) { if (/cancelled/.test(String(e))) return false; throw e; } + if (!r?.path) return false; + const hero = await api.setArtistHero(id, r.path); + forgetArtist(name); + note(`Hero set · ${hero.width}×${hero.height}`); + return true; +} + +/** The chosen hero removed — the catalog's own photos come back. */ +export async function heroClear(id, name) { + const removed = await api.clearArtistHero(id); + forgetArtist(name); + note(removed ? "The catalog's own photos are back" : "No custom hero here"); +} + export function lookupAlbum(id) { if (id == null) return; const cur = catalog.albums[id]; diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte index a9802c9..3e2ce85 100644 --- a/src/mobile/screens/Artist.svelte +++ b/src/mobile/screens/Artist.svelte @@ -19,7 +19,7 @@ import { art, wantArt } from "../art.svelte.js"; import { player, playEntries, playAlbum } from "../player.svelte.js"; import { push, openHalf } from "../nav.svelte.js"; - import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js"; + import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, picture } from "../catalog.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js"; import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js"; import { shuffled, mmss, ago } from "../util.js"; @@ -64,6 +64,10 @@ const nm = page?.artist?.name || name; if (id != null) untrack(() => loadExtras(id, nm)); }); + /* Popular's own lane, asked the moment an id is known — from the + search's own row, or from the identity search — so the section can + paint before the full page is built */ + $effect(() => { const id = artistId; if (id != null) untrack(() => loadPopular(id)); }); const seedSrc = $derived(seedPath ? art[seedPath] : ""); const heroSrc = $derived(heroUrl ? art[heroUrl] : ""); const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null); @@ -88,7 +92,11 @@ }); /* ── Popular ─────────────────────────────────────────────────────── */ - const popularAll = $derived(page?.popular_tracks ?? []); + /* the page's own list once it lands (the same Last.fm order, from the + same inputs); the fast lane's until then, and for an artist whose + page came back with no Popular at all */ + const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []); + const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular); let showAllPopular = $state(false); const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null }))); const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0)); diff --git a/src/mobile/screens/CatalogAlbum.svelte b/src/mobile/screens/CatalogAlbum.svelte index 9cab117..14ac0f7 100644 --- a/src/mobile/screens/CatalogAlbum.svelte +++ b/src/mobile/screens/CatalogAlbum.svelte @@ -12,7 +12,7 @@ import { albumsBy, tracksOf } from "../lib.svelte.js"; import { player, playAlbum, playAlbumShuffled, toggleShuffle } from "../player.svelte.js"; import { push, openHalf } from "../nav.svelte.js"; - import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey } from "../catalog.svelte.js"; + import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey, loadTrackListeners } from "../catalog.svelte.js"; import { resolver, streamSong, streamCatalogAlbum, saveCatalogAlbum } from "../resolve.svelte.js"; import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js"; import { mmss, longDur } from "../util.js"; @@ -70,7 +70,14 @@ const total = $derived(rows.reduce((n, t) => n + (t.duration_secs || 0), 0)); const runtime = $derived(total ? longDur(total) : ""); const multiDisc = $derived(new Set(rows.map((t) => t.disk_number ?? 1)).size > 1); - const peak = $derived(rows.reduce((m, t) => Math.max(m, t.listeners || 0), 0)); + /* the rows the record's own artist anchor found no count for — a + feature's guest, a compilation's stranger — get the deferred pass by + their OWN artist id; a record's stats are anchored on its artist + alone and cannot reach them */ + $effect(() => { const rs = rows, k = id; if (rs.length) untrack(() => loadTrackListeners(k, rs)); }); + const extraCounts = $derived(catalog.listeners[id]?.counts ?? {}); + const listenersOf = (t) => t.listeners ?? extraCounts[t.id] ?? null; + const peak = $derived(rows.reduce((m, t) => Math.max(m, listenersOf(t) || 0), 0)); const showCount = $derived(peak > 0 && rows.length >= 3); const hasUnreleased = $derived(rows.some((t) => t.unreleased)); const fmtCount = (n) => (!n ? "" : n >= 1e6 ? (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n)); @@ -187,7 +194,7 @@ {t.title}{#if hit}{/if} {#if !hit && !libAlbum && current(t) && resolver.active}finding the best source…{/if} - {#if showCount}{fmtCount(t.listeners)}{/if} + {#if showCount}{fmtCount(listenersOf(t))}{/if} {t.unreleased ? "Unreleased" : mmss(t.duration_secs)} diff --git a/src/mobile/screens/You.svelte b/src/mobile/screens/You.svelte index 19c63b8..742a705 100644 --- a/src/mobile/screens/You.svelte +++ b/src/mobile/screens/You.svelte @@ -106,6 +106,7 @@ const artMode = $derived(cfg?.replace_embedded_art === false ? "keep" : "replace"); const artEdge = $derived(String(cfg?.embed_art_max_edge ?? 1500)); let refetching = $state(false); + let refreshingArtists = $state(false); async function saveCfg(patch) { try { await api.setSettings(patch); cfg = await api.getSettings(); } catch (e) { player.error = String(e); } } @@ -136,6 +137,14 @@ catch (e) { player.error = String(e); } finally { refetching = false; } } + /* the desktop Settings row: a portrait for every artist already here */ + async function refreshPortraits() { + if (refreshingArtists) return; + refreshingArtists = true; + try { const n = await api.refreshArtistArt(); note(`${n ?? 0} portrait${n === 1 ? "" : "s"} refreshed`); } + catch (e) { player.error = String(e); } + finally { refreshingArtists = false; } + } const ROWS = [ ["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"], ["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"], @@ -309,6 +318,10 @@ {refetching ? "Upgrading covers…" : "Re-fetch all album covers"}every record to the canonical high-resolution cover — about one a second +
This phone
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 0b8df70..7630706 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -167,6 +167,9 @@ const PROBE = `(function () { hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(), cardImgs: document.querySelectorAll('.rail .acard img').length, cardPhs: document.querySelectorAll('.rail .acard svg').length, + popCounts: [].filter.call(document.querySelectorAll('.row.pop .lbar em'), function (e) { return e.textContent.trim(); }).length, + barCounts: [].filter.call(document.querySelectorAll('.trk .lbar em'), function (e) { return e.textContent.trim(); }).length, + heroPix: !!document.querySelector('.hero .hero-img.dissolve'), likes: [].map.call(document.querySelectorAll('.tastechips.likes .tchip'), function (c) { return c.textContent.trim(); }).join('|'), hates: [].map.call(document.querySelectorAll('.tastechips.hates .tchip'), function (c) { return c.textContent.trim(); }).join('|'), similar: document.querySelectorAll('.screen .row.similar').length, @@ -318,6 +321,12 @@ const MOCK_STEPS = [ { do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 }, { do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 }, { do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 }, + /* the artist's photo: the page's ⋯ → a picture from this phone becomes + the hero, the page is asked again, then the catalog's own come back */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 }, + { do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 }, + { do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 }, { do: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 }, { do: `history.back()`, wait: 600 }, { do: `history.back()`, wait: 800 }, @@ -362,6 +371,8 @@ const MOCK_STEPS = [ { do: `__settle()`, wait: 800 }, { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 }, { do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 }, + /* the two guest rows the record's own anchor had no count for */ + { name: "cat-album-listeners", wait: 900 }, /* the record page's own sheet from the bar */ { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, @@ -488,6 +499,8 @@ const MOCK_STEPS = [ { do: `__click('.screen .row', 'Word-by-word')`, wait: 400 }, { do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 }, { do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 }, + /* the desktop's portrait refresh, beside the covers' */ + { do: `__click('.screen .row', 'Refresh artist portraits')`, name: "you-portraits", wait: 900 }, /* downloads on Wi-Fi only: the switch on, the connection made cellular, a download refused; then off */ { do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-on", wait: 400 }, { do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 400 }, @@ -538,6 +551,7 @@ const MOCK_EXPECT = { "you-slots": (i) => i.slots === "3", "you-words": (i) => i.wordfill === "on", "you-artedge": (i) => i.artEdge === "1000 px", + "you-portraits": (i) => /portraits refreshed/.test(i.notice), "wifi-on": (i) => i.wifiOnly === "on", "wifi-refused": (i) => /Wi-Fi/.test(i.toast), "wifi-off": (i) => i.wifiOnly === "off" && !i.toast, @@ -602,7 +616,11 @@ const MOCK_EXPECT = { "cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2, "cat-artist-follow": (i) => i.fb.includes("Following"), "cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2, - "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero"), + "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero") && i.heroPix && i.pop >= 4 && i.popCounts >= 4, + "artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText), + "artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice), + "artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix, + "artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice), "net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live), "net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"), "net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"), @@ -624,6 +642,7 @@ const MOCK_EXPECT = { "browse-dir": (i) => i.brwFiles >= 8, "browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10, + "cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14, "cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText), "cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText), "cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"), @@ -806,6 +825,10 @@ const PHONE_STEPS = [ { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, { do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 }, { name: "artist", wait: 4500 }, + /* the page's ⋯ on the real core: the hero door is there and knows the + artist's id — it opens the system picker, so the walk only looks */ + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 600 }, /* a discography card the library does NOT hold opens the catalog record */ { do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 }, { do: `history.back()`, wait: 800 }, @@ -873,6 +896,7 @@ const PHONE_EXPECT = { "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), "share-cancel": (i) => i.title.startsWith("Your library"), artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1, + "artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText), "catalog-album": (i) => i.screen.includes("tr-push") && !!i.title, end: (i) => /^(Good |Still)/.test(i.title), };