home: a landing page built from the app's own parts

Approved on research/home-redesign after four passes: the Desk layout,
the Showcase polish, the welcome card, a 6-month feed window, news with
reviews filtered, and all three of the sections I proposed.

Nothing here is a new design. The release cards, their grid, the date
chip and its accent-glass recipe, the upcoming wording and the missing
play button on an unreleased record are the artist page's. The section
heads, the featured card, the artist cards and the list rows are
ArtistExtras'. The entrance is CatalogDiscography's edition deal with the
same four approved numbers, and the arrival rule with it — a null from
consumeScroll means a fresh arrival, which is the only thing that deals;
coming Back is a return to something already read. Only the rails, the
aside panels and the news list are new vocabulary, and those came off the
mock verbatim. The styles are copies rather than imports for the same
reason the discography page carries copies of the album page's: a page
that reaches into another page's stylesheet couples them forever.

The feed is what "new" honestly means. Deezer's release list merged with
the MusicBrainz identity supplement — A ? of WHEN is a Panda Bear record
that exists only in the supplement, and Weezer's upcoming album only
carries its real name (Gold Album) there — then deduped per artist by
cover and by base title, which collapses Weezer's BBC triplet and
Röyksopp's four same-day remix drops to one card each. Upcoming first,
then newest, with the age riding the artwork so the reader never has to
work out whether "new" means this week or last spring. Jump back in is
deliberately NOT deduped against it: a record can be new and also what
you were just listening to, and it belongs in both.

Everything reads caches. Assembling this feed cold costs one
catalog_artist call per followed artist — 48 seconds for nine, measured —
so home_warm walks the follows in the background through the same entry
points the pages use and emits per artist as it goes. The page fills in
progressively instead of waiting for the last one, and a follow change
re-walks it.

Three sections were mine to propose and are now in: the back catalogue
(albums older than the window, one per artist first so a deep discography
cannot own the row), the library rail, and Complete the set — you own two
of sixteen Weezer albums and a new one lands in seventeen days.
This commit is contained in:
KeelrLeek committed 2026-08-04 19:08:58 -05:00
1 parent 8ef730848a
commit 034eac9569
6 files changed
+1496 -48

No files matched your search

+34
View File
@@ -4223,6 +4223,40 @@ pub async fn home_artist_news(
Ok(out)
}
/// Warm every cache the home page reads, in the background.
///
/// Measured 2026-08-04: assembling the release feed cold costs one
/// catalog_artist call per follow — 48 seconds for nine artists — which is why
/// the page itself must only ever read caches. This walks the follows through
/// the same metadata entry points the pages use (so the 15-minute catalog and
/// 24-hour identity caches fill), then tells the frontend fresh data is there.
#[tauri::command]
pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> Result<(), String> {
let follows = {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())??
};
let metadata = lib.metadata.clone();
tauri::async_runtime::spawn(async move {
for f in follows {
// Page payload first (releases), then the MB supplement the feed
// merges (A ? of WHEN exists only there), then the related pool.
let _ = metadata.catalog_artist(f.artist_id).await;
let _ = metadata
.catalog_artist_identity(f.artist_id, &f.name)
.await;
let _ = metadata.catalog_related(f.artist_id).await;
// One artist at a time and an emit per artist: the page fills in
// progressively instead of all-at-once after the last follow.
let _ = app.emit("soulseek:home-warmed", f.artist_id);
}
let _ = app.emit("soulseek:home-ready", ());
});
Ok(())
}
// ---- playlists ----
fn now_secs() -> i64 {
+1
View File
@@ -90,6 +90,7 @@ pub fn run() {
commands::follow_artist,
commands::unfollow_artist,
commands::home_artist_news,
commands::home_warm,
commands::list_playlists,
commands::create_playlist,
commands::delete_playlist,
+5
View File
@@ -68,6 +68,11 @@
"catalogAlbum",
"catalog",
"catalogDiscography",
// Home is a "start BELOW the bar" page (App's search-page rule): it
// has no artwork to run under the frost, so it takes back
// --bar-h + air rather than --pane-pad. Its ambience wash is the one
// exception and deliberately runs to the pane top behind the glass.
"home",
]);
initEvents();
+6
View File
@@ -111,6 +111,12 @@ export const followedArtists = () => invoke("followed_artists");
export const followArtist = (artistId, name, portrait) =>
invoke("follow_artist", { artistId, name, portrait: portrait ?? null });
export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId });
// Home landing page. `homeWarm` walks the follows through the same metadata
// entry points the pages use so every cache the feed reads is hot (measured
// 48s cold for 9 artists — the page itself must never pay that); `homeArtistNews`
// is one paced Google News query per followed artist, answered from a 6h cache.
export const homeWarm = () => invoke("home_warm");
export const homeArtistNews = (artists) => invoke("home_artist_news", { artists });
// The OS media card (Windows SMTC). Only the metadata crosses IPC and only on
// a track change — status and position are pushed by the backend's own player
// event pump, which already has both. See src-tauri/src/smtc.rs.
+86
View File
@@ -127,6 +127,81 @@ export async function setFollowed(artist, on) {
}
}
/* ------------------------------------------------------------- home page
* Raw material for the landing page, one slot per followed artist. The
* page derives everything visible (the deduped feed, the windows, the news
* filter) exactly as the artist page derives its tabs — the store holds
* payloads, not conclusions.
*
* Assembly is cheap by construction: home_warm walks the follows through
* the same metadata entry points in the background (48s cold, measured),
* and these reads then land in warmed caches. Progressive on purpose — a
* slot appears as its artist resolves rather than after the last one.
*/
export const homeStore = $state({
started: false,
follows: [],
/** artistId -> { id, name, picture, albums, mb, related, error } */
slots: {},
order: [],
recents: [],
/** [{ artist, title, url, source, date(unix secs) }] newest first */
news: [],
newsLoaded: false,
});
async function loadHomeArtist(f) {
const slot = { id: f.artist_id, name: f.name, picture: f.portrait ?? null, albums: [], mb: [], related: [] };
try {
const page = await api.catalogArtist(f.artist_id);
slot.name = page?.artist?.name ?? f.name;
slot.picture = page?.artist?.picture ?? slot.picture;
slot.albums = (page?.albums ?? []).filter((a) => !a.appears_on);
} catch {
slot.error = true;
}
try {
const identity = await api.catalogArtistIdentity(f.artist_id, slot.name);
slot.mb = identity?.albums ?? [];
} catch {}
try {
slot.related = ((await api.catalogRelated(f.artist_id)) ?? []).slice(0, 6);
} catch {}
homeStore.slots[f.artist_id] = slot;
}
/** Bring the landing page's material in. Safe to call repeatedly — the
* backend caches make a re-walk cost milliseconds once warm. */
export async function loadHome(force = false) {
if (homeStore.started && !force) return;
homeStore.started = true;
// The warm task first: it fills the backend caches this walk reads, and
// emits soulseek:home-warmed per artist so late slots refresh themselves.
void api.homeWarm().catch(() => {});
try {
const follows = await api.followedArtists();
homeStore.follows = follows;
homeStore.order = follows.map((f) => f.artist_id);
} catch {
return;
}
try {
homeStore.recents = (await api.recentAlbums(14, null)) ?? [];
} catch {}
for (const f of homeStore.follows) {
if (force || !homeStore.slots[f.artist_id]) await loadHomeArtist(f);
}
if (!homeStore.newsLoaded || force) {
try {
const byArtist = await api.homeArtistNews(homeStore.follows.map((f) => f.name));
homeStore.news = Object.entries(byArtist)
.flatMap(([artist, items]) => (items ?? []).map((n) => ({ ...n, artist })))
.sort((a, b) => b.date - a.date);
homeStore.newsLoaded = true;
} catch {}
}
}
/** The bottom toast (approved T2: portrait pill). `seq` is the trigger — every
* announcement bumps it, so following twice in a row still re-shows. */
export const followToast = $state({ seq: 0, on: false, name: "", portrait: null });
@@ -5391,6 +5466,17 @@ export function initEvents() {
api.onEvent("soulseek:playlists-changed", refreshPlaylists);
// The home warm task announces each artist it finishes; refreshing that
// one slot turns the walk into a page that fills in as it goes. A follow
// change re-walks (and re-warms) the whole set.
api.onEvent("soulseek:home-warmed", (artistId) => {
const f = homeStore.follows.find((x) => x.artist_id === artistId);
if (f && homeStore.started) void loadHomeArtist(f);
});
api.onEvent("soulseek:follows-changed", () => {
if (homeStore.started) void loadHome(true);
});
// The OS media card and the hardware media keys, arriving through Windows'
// SMTC (src-tauri/src/smtc.rs). They run the SAME functions the on-screen
// transport runs — the queue, shuffle, repeat and the re-source of a
+1364 -48
View File
File diff suppressed because it is too large. Load diff