From 034eac9569e7ce36dd0ea5d1eb5411859fcb6485 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:08:58 -0500 Subject: [PATCH] home: a landing page built from the app's own parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 34 + src-tauri/src/lib.rs | 1 + src/App.svelte | 5 + src/lib/api.js | 6 + src/lib/state.svelte.js | 86 +++ src/views/Home.svelte | 1424 +++++++++++++++++++++++++++++++++++-- 6 files changed, 1502 insertions(+), 54 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c25687b..32f5fcb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d5b93a0..0ced8eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index 93fdb26..a00b320 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(); diff --git a/src/lib/api.js b/src/lib/api.js index 3c1c2a1..4e67c3a 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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. diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 48e6d9f..793cf3a 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -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 diff --git a/src/views/Home.svelte b/src/views/Home.svelte index ec9a92f..977fe36 100644 --- a/src/views/Home.svelte +++ b/src/views/Home.svelte @@ -1,63 +1,1390 @@ -
Welcome to SoulStream
++ Search the network, stream instantly, and follow artists — new + releases, your library and their news will land here. +
+ +Welcome to SoulStream
-- Search the network, stream instantly, and downloaded albums - will show up here. -
- -