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), };