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. -
- -