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:
1 parent
c737809325
commit
1737c5e1b6
12 files changed
+232
-13
No files matched your search
@@ -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 @@
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))}
|
||||
</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"}
|
||||
<div class="sheet-title">SoulStream</div>
|
||||
<div class="sheet-rows">
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 @@
|
||||
<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>
|
||||
{#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 class="d">{t.unreleased ? "Unreleased" : mmss(t.duration_secs)}</span>
|
||||
</button>
|
||||
|
||||
@@ -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 @@
|
||||
<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>
|
||||
</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 class="sheet-sec">This phone</div>
|
||||
|
||||
Reference in new issue
Block a user