android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo

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.
This commit is contained in:
KeelrLeek committed 2026-09-13 23:46:07 -05:00
1 parent c737809325
commit 1737c5e1b6
12 files changed
+232 -13

No files matched your search

+19 -1
View File
@@ -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 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 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 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, 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. a word only when a real name is within two edits.
+26 -3
View File
@@ -2017,10 +2017,21 @@ pub async fn pick_artist_hero(
let Some(source) = rx.await.unwrap_or(None) else { let Some(source) = rx.await.unwrap_or(None) else {
return Ok(None); 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<ArtistHeroOverride, String> {
let source = std::path::PathBuf::from(source); let source = std::path::PathBuf::from(source);
let (width, height) = validate_artist_hero(&source)?; let (width, height) = validate_artist_hero(&source)?;
let (ext, _) = artist_image_format(&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")); let temporary = dir.join(format!("{artist_id}.importing"));
std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?; std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?;
for old_ext in ["jpg", "png", "webp"] { for old_ext in ["jpg", "png", "webp"] {
@@ -2031,11 +2042,23 @@ pub async fn pick_artist_hero(
let _ = std::fs::remove_file(&temporary); let _ = std::fs::remove_file(&temporary);
return Err(error.to_string()); 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())?; .ok_or_else(|| "The imported hero could not be reopened".to_string())?;
hero.width = width; hero.width = width;
hero.height = height; 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<ArtistHeroOverride, String> {
install_artist_hero(&app, artist_id, path)
} }
#[tauri::command] #[tauri::command]
+1
View File
@@ -325,6 +325,7 @@ pub fn run() {
commands::catalog_artist_overview, commands::catalog_artist_overview,
commands::catalog_artist_popular, commands::catalog_artist_popular,
commands::pick_artist_hero, commands::pick_artist_hero,
commands::set_artist_hero,
commands::clear_artist_hero, commands::clear_artist_hero,
commands::catalog_related, commands::catalog_related,
commands::catalog_track_listeners, commands::catalog_track_listeners,
+3
View File
@@ -403,6 +403,9 @@ export const catalogArtistPopular = (artistId) =>
invoke("catalog_artist_popular", { artistId }); invoke("catalog_artist_popular", { artistId });
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId }); export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
export const clearArtistHero = (artistId) => invoke("clear_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 }); export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
// Deferred Last.fm pass for search results: `tracks` is [{ artistId, title }] // 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, // and the answer comes back in the same order. Kept OUT of catalog_search,
+31 -2
View File
@@ -370,6 +370,9 @@ export function mockInvoke(cmd, args) {
if (cmd === "album_tracks") if (cmd === "album_tracks")
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60)); return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
if (cmd === "album_art") { 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 m = /(\d+)$/.exec(args.path || "");
const num = m ? +m[1] : 0; const num = m ? +m[1] : 0;
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26)))); 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 }, { 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 })); 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 = []; let follows = [];
function mockCatalog(cmd, args) { function mockCatalog(cmd, args) {
if (cmd === "catalog_artist") { if (cmd === "catalog_artist") {
@@ -502,7 +507,7 @@ function mockCatalog(cmd, args) {
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist")); if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
const own = a === CAT_ARTIST; const own = a === CAT_ARTIST;
return new Promise((r) => setTimeout(() => r({ 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 })) : [], 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 }], 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: [], 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_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_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); 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 === "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 === "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(); } 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") { if (cmd === "catalog_album") {
const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId)); const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId));
if (!a) return Promise.reject(new Error("catalog_album: unknown album")); 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") { if (cmd === "catalog_album_info") {
const t = String(args?.title || ""); const t = String(args?.title || "");
@@ -558,6 +586,7 @@ function mockCatalog(cmd, args) {
if (cmd === "rescan_library") return Promise.resolve(); if (cmd === "rescan_library") return Promise.resolve();
if (cmd === "library_art_prewarm") 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_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 === "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 === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
if (cmd === "preload_stream") { if (cmd === "preload_stream") {
+21 -1
View File
@@ -11,7 +11,7 @@
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js"; 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 { player, playNext, enqueue, playEntries, playQueueAt, removeFromQueue, moveToNext, moveInQueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.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 { 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 { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.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 === "library") return "library-more";
if (t.n === "playlist") return "playlist-more"; if (t.n === "playlist") return "playlist-more";
if (t.n === "catalogAlbum") return "catalogAlbum-more"; if (t.n === "catalogAlbum") return "catalogAlbum-more";
if (t.n === "artist") return "artist-more";
return "home-more"; return "home-more";
}); });
@@ -682,6 +683,25 @@
<div class="sheet-hair"></div> <div class="sheet-hair"></div>
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))} {@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))}
</div> </div>
{: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}
<div class="sheet-head">
<span class="art">{#if picture(pg?.artist?.picture)}<img src={picture(pg.artist.picture)} alt="" />{:else}<Icon name="users" size={22} />{/if}</span>
<span class="b"><span class="t">{nm}</span><span class="m">{pg?.albums?.length ? pg.albums.length + " releases on the catalog" : "Artist"}</span></span>
</div>
<div class="sheet-rows">
{#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}
<div class="sheet-hair"></div>
{/if}
{@render row("settings", "Artwork settings", "covers and portraits, in You", act(() => go("you")))}
</div>
{:else if kind === "home-more"} {:else if kind === "home-more"}
<div class="sheet-title">SoulStream</div> <div class="sheet-title">SoulStream</div>
<div class="sheet-rows"> <div class="sheet-rows">
+3
View File
@@ -23,6 +23,9 @@ let inFlight = 0;
export function loadArt(path, urgent = false) { export function loadArt(path, urgent = false) {
if (!path) return Promise.resolve(""); if (!path) return Promise.resolve("");
if (art[path] !== undefined) return Promise.resolve(art[path]); 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 /* 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 of it is here (player.svelte.js sets it); nothing to fetch, and a miss
must not be remembered */ must not be remembered */
+70
View File
@@ -7,6 +7,7 @@
exact-name matches, the most-followed wins, so a shared name resolves to 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. */ the canonical artist and never to a namesake the search listed first. */
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { note } from "./notice.svelte.js";
export const catalog = $state({ export const catalog = $state({
/** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */ /** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */
@@ -23,6 +24,11 @@ export const catalog = $state({
seeds: {}, seeds: {},
/** "artist\u0000title" → { status, info: { description, genre, label, source } } */ /** "artist\u0000title" → { status, info: { description, genre, label, source } } */
albumInfo: {}, 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) { 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. */ /** A portrait for a byline — the same identity rule, search only. */
export function seedFor(name) { export function seedFor(name) {
const k = artistKey(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) { export function lookupAlbum(id) {
if (id == null) return; if (id == null) return;
const cur = catalog.albums[id]; const cur = catalog.albums[id];
+10 -2
View File
@@ -19,7 +19,7 @@
import { art, wantArt } from "../art.svelte.js"; import { art, wantArt } from "../art.svelte.js";
import { player, playEntries, playAlbum } from "../player.svelte.js"; import { player, playEntries, playAlbum } from "../player.svelte.js";
import { push, openHalf } from "../nav.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 { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js"; import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
import { shuffled, mmss, ago } from "../util.js"; import { shuffled, mmss, ago } from "../util.js";
@@ -64,6 +64,10 @@
const nm = page?.artist?.name || name; const nm = page?.artist?.name || name;
if (id != null) untrack(() => loadExtras(id, nm)); 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 seedSrc = $derived(seedPath ? art[seedPath] : "");
const heroSrc = $derived(heroUrl ? art[heroUrl] : ""); const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null); const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
@@ -88,7 +92,11 @@
}); });
/* ── Popular ─────────────────────────────────────────────────────── */ /* ── 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); let showAllPopular = $state(false);
const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null }))); 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)); const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
+10 -3
View File
@@ -12,7 +12,7 @@
import { albumsBy, tracksOf } from "../lib.svelte.js"; import { albumsBy, tracksOf } from "../lib.svelte.js";
import { player, playAlbum, playAlbumShuffled, toggleShuffle } from "../player.svelte.js"; import { player, playAlbum, playAlbumShuffled, toggleShuffle } from "../player.svelte.js";
import { push, openHalf } from "../nav.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 { resolver, streamSong, streamCatalogAlbum, saveCatalogAlbum } from "../resolve.svelte.js";
import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js"; import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js";
import { mmss, longDur } from "../util.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 total = $derived(rows.reduce((n, t) => n + (t.duration_secs || 0), 0));
const runtime = $derived(total ? longDur(total) : ""); const runtime = $derived(total ? longDur(total) : "");
const multiDisc = $derived(new Set(rows.map((t) => t.disk_number ?? 1)).size > 1); 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 showCount = $derived(peak > 0 && rows.length >= 3);
const hasUnreleased = $derived(rows.some((t) => t.unreleased)); 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)); 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 @@
<span class="b"> <span class="b">
<span class="t">{t.title}{#if hit}<span class="tick" title="In your library"><Icon name="check" size={11} strokeWidth={3} /></span>{/if}</span> <span class="t">{t.title}{#if hit}<span class="tick" title="In your library"><Icon name="check" size={11} strokeWidth={3} /></span>{/if}</span>
{#if !hit && !libAlbum && current(t) && resolver.active}<span class="m">finding the best source…</span>{/if} {#if !hit && !libAlbum && current(t) && resolver.active}<span class="m">finding the best source…</span>{/if}
{#if showCount}<span class="lbar"><i style="transform: scaleX({(peak ? Math.max(0.04, (t.listeners || 0) / peak) : 0).toFixed(3)})"></i><em>{fmtCount(t.listeners)}</em></span>{/if} {#if showCount}<span class="lbar"><i style="transform: scaleX({(peak ? Math.max(0.04, (listenersOf(t) || 0) / peak) : 0).toFixed(3)})"></i><em>{fmtCount(listenersOf(t))}</em></span>{/if}
</span> </span>
<span class="d">{t.unreleased ? "Unreleased" : mmss(t.duration_secs)}</span> <span class="d">{t.unreleased ? "Unreleased" : mmss(t.duration_secs)}</span>
</button> </button>
+13
View File
@@ -106,6 +106,7 @@
const artMode = $derived(cfg?.replace_embedded_art === false ? "keep" : "replace"); const artMode = $derived(cfg?.replace_embedded_art === false ? "keep" : "replace");
const artEdge = $derived(String(cfg?.embed_art_max_edge ?? 1500)); const artEdge = $derived(String(cfg?.embed_art_max_edge ?? 1500));
let refetching = $state(false); let refetching = $state(false);
let refreshingArtists = $state(false);
async function saveCfg(patch) { async function saveCfg(patch) {
try { await api.setSettings(patch); cfg = await api.getSettings(); } catch (e) { player.error = String(e); } 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); } catch (e) { player.error = String(e); }
finally { refetching = false; } 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 = [ const ROWS = [
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"], ["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"], ["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
@@ -309,6 +318,10 @@
<span class="lead"><Icon name="refresh" size={20} /></span> <span class="lead"><Icon name="refresh" size={20} /></span>
<span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span> <span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span>
</button> </button>
<button class="row" onclick={refreshPortraits} disabled={refreshingArtists}>
<span class="lead"><Icon name="users" size={20} /></span>
<span class="b"><span class="t">{refreshingArtists ? "Refreshing portraits…" : "Refresh artist portraits"}</span><span class="m">a photo for every artist already in your library</span></span>
</button>
</div> </div>
<div class="sheet-sec">This phone</div> <div class="sheet-sec">This phone</div>
+25 -1
View File
@@ -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; } })(), 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, cardImgs: document.querySelectorAll('.rail .acard img').length,
cardPhs: document.querySelectorAll('.rail .acard svg').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('|'), 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('|'), hates: [].map.call(document.querySelectorAll('.tastechips.hates .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
similar: document.querySelectorAll('.screen .row.similar').length, 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('.top button', 'Open')`, name: "cat-artist", wait: 2200 },
{ do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 }, { do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 },
{ do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 }, { 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: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 },
{ do: `history.back()`, wait: 600 }, { do: `history.back()`, wait: 600 },
{ do: `history.back()`, wait: 800 }, { do: `history.back()`, wait: 800 },
@@ -362,6 +371,8 @@ const MOCK_STEPS = [
{ do: `__settle()`, wait: 800 }, { do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 }, { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 },
{ do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 }, { 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 */ /* the record page's own sheet from the bar */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 }, { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 }, { 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('.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', '1000 px')`, name: "you-artedge", wait: 500 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 }, { 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 */ /* 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: `__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 }, { 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-slots": (i) => i.slots === "3",
"you-words": (i) => i.wordfill === "on", "you-words": (i) => i.wordfill === "on",
"you-artedge": (i) => i.artEdge === "1000 px", "you-artedge": (i) => i.artEdge === "1000 px",
"you-portraits": (i) => /portraits refreshed/.test(i.notice),
"wifi-on": (i) => i.wifiOnly === "on", "wifi-on": (i) => i.wifiOnly === "on",
"wifi-refused": (i) => /Wi-Fi/.test(i.toast), "wifi-refused": (i) => /Wi-Fi/.test(i.toast),
"wifi-off": (i) => i.wifiOnly === "off" && !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": (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-follow": (i) => i.fb.includes("Following"),
"cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2, "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-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-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
"net-arrived": (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-dir": (i) => i.brwFiles >= 8,
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live), "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": (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-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-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"), "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: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
{ do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 }, { do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 },
{ name: "artist", wait: 4500 }, { 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 */ /* 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: `(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 }, { 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-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"), "share-cancel": (i) => i.title.startsWith("Your library"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1, 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, "catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
end: (i) => /^(Good |Still)/.test(i.title), end: (i) => /^(Good |Still)/.test(i.title),
}; };