From 520e2ad4e4a91b307d3912cb2d6ea8263f8005fb Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:43:27 -0500 Subject: [PATCH] follow: the artist page's heart becomes a real thing, stored and celebrated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following an artist now writes a row: followed_artists (artist_id, name, portrait, followed_at) beside the playlists in the library db, reached through follow_artist / unfollow_artist / followed_artists, each emitting soulseek:follows-changed. Re-following refreshes the name and portrait — identity improves as pages are visited and a later portrait is usually the better one — but keeps the original followed_at, so "following since" stays honest. Name and portrait are stored on purpose rather than looked up: the home page's "new releases by artists you follow" can then draw a card from the row alone, with no catalog request per artist. The store is the UI truth and the chip never keeps its own copy. It is optimistic — the chip flips at the click and reverts if the write fails, rather than leaving the screen telling a lie. The chip itself (FB4) leads the stat row and wears the accent as ground with dark text, the Play pill's own trick, so its on-state is legible across the room. The lead slot is a fixed 14x14 in which the +, the equalizer bars and the check swap places, so nothing about the chip's box moves except the word — and the word morphs between its measured widths instead of snapping. Clicking runs the Encore: the + collapses, three bars dance three beats with a note rising per beat, they bow, and the check springs in as "Following" grows into place. The db write and the toast fire at the CLICK; the ceremony is presentation only, so navigating away mid-show still follows. Unfollow is an undo — the shrink morphs, nothing celebrates, and the toast says so. Every effect is template-owned with scoped keyframes. The first build spawned the glow and the notes imperatively and referenced -global- keyframes, which silently did not resolve: the glow ran no animation and sat at full opacity, the notes held opacity 0 and never appeared. The bars worked throughout for exactly the reason the others did not. The row around it is shorter too, at Kyle's call: the library and listening chips (progress meter, the disk popover, format, hours, last played, top track, genre) come out, leaving the identity trio and the socials. With nothing left to shed, the socials' scroll-driven fade and their layout drop at the park boundary come out with them, and the divider's clear space moves from the follow heart by name to whichever chip now closes the run. The JS that fed the removed chips stays — it is cheap, local, and the numbers still have a home coming. --- crates/library/src/lib.rs | 67 ++++ src-tauri/src/commands.rs | 46 +++ src-tauri/src/lib.rs | 3 + src/App.svelte | 2 + src/components/FollowToast.svelte | 154 ++++++++ src/lib/api.js | 6 + src/lib/state.svelte.js | 55 +++ src/views/CatalogArtist.svelte | 627 ++++++++++++++++++++---------- 8 files changed, 763 insertions(+), 197 deletions(-) create mode 100644 src/components/FollowToast.svelte diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index e3986d3..858beab 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -58,6 +58,17 @@ pub struct PlaylistTrack { pub art_path: Option, } +/// One followed artist. Name and portrait are stored alongside the id so the +/// future home page can render "artists you follow" without paying a catalog +/// request per artist — the row alone is enough to draw a card. +#[derive(Debug, Clone, Serialize)] +pub struct FollowedArtist { + pub artist_id: u64, + pub name: String, + pub portrait: Option, + pub followed_at: i64, +} + /// Cumulative listening stats for one artist (drives the artist-page chips). #[derive(Debug, Clone, Serialize)] pub struct PlayStats { @@ -161,6 +172,12 @@ impl Library { ); CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE); CREATE INDEX IF NOT EXISTS idx_plays_started ON plays(started_at DESC); + CREATE TABLE IF NOT EXISTS followed_artists ( + artist_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + portrait TEXT, + followed_at INTEGER NOT NULL + ); ", )?; // Migrate databases created before artist portraits were added. @@ -612,6 +629,56 @@ impl Library { Ok(()) } + // ---- followed artists ---- + + /// Idempotent: re-following refreshes the stored name/portrait (identity + /// improves over time — a page visit can carry a better portrait) but + /// keeps the original followed_at, so "following since" stays honest. + pub fn follow_artist( + &self, + artist_id: u64, + name: &str, + portrait: Option<&str>, + now: i64, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO followed_artists (artist_id, name, portrait, followed_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(artist_id) + DO UPDATE SET name = ?2, portrait = COALESCE(?3, portrait)", + rusqlite::params![artist_id as i64, name, portrait, now], + )?; + Ok(()) + } + + pub fn unfollow_artist(&self, artist_id: u64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM followed_artists WHERE artist_id = ?1", + [artist_id as i64], + )?; + Ok(()) + } + + /// Newest follow first — the order a home-page rail wants. + pub fn followed_artists(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT artist_id, name, portrait, followed_at + FROM followed_artists ORDER BY followed_at DESC", + )?; + let rows = stmt.query_map([], |r| { + Ok(FollowedArtist { + artist_id: r.get::<_, i64>(0)? as u64, + name: r.get(1)?, + portrait: r.get(2)?, + followed_at: r.get(3)?, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + // ---- playlists ---- pub fn create_playlist(&self, name: &str, now: i64) -> Result { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 629e6c0..a94332d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4158,6 +4158,52 @@ pub async fn artist_play_stats( .map_err(|e| e.to_string())? } +// ---- followed artists ---- + +#[tauri::command] +pub async fn followed_artists( + lib: State<'_, LibraryState>, +) -> Result, String> { + 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())? +} + +#[tauri::command] +pub async fn follow_artist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + artist_id: u64, + name: String, + portrait: Option, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.follow_artist(artist_id, &name, portrait.as_deref(), now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:follows-changed", ()); + Ok(()) +} + +#[tauri::command] +pub async fn unfollow_artist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + artist_id: u64, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.unfollow_artist(artist_id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:follows-changed", ()); + Ok(()) +} + // ---- playlists ---- fn now_secs() -> i64 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 496833c..5da4c3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -77,6 +77,9 @@ pub fn run() { commands::recent_albums, commands::clear_play_history, commands::play_row_count, + commands::followed_artists, + commands::follow_artist, + commands::unfollow_artist, commands::list_playlists, commands::create_playlist, commands::delete_playlist, diff --git a/src/App.svelte b/src/App.svelte index c33b83b..83a8b90 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,6 +19,7 @@ import CatalogSearch from "./views/CatalogSearch.svelte"; import CatalogArtist from "./views/CatalogArtist.svelte"; import CatalogAlbum from "./views/CatalogAlbum.svelte"; + import FollowToast from "./components/FollowToast.svelte"; import { ui, initEvents, @@ -97,6 +98,7 @@ + diff --git a/src/components/FollowToast.svelte b/src/components/FollowToast.svelte new file mode 100644 index 0000000..181eb04 --- /dev/null +++ b/src/components/FollowToast.svelte @@ -0,0 +1,154 @@ + + +{#if shown} +
+
+ {#if followToast.portrait} + + {:else} + + {/if} + {#if followToast.on} + You're now following {followToast.name} + {:else} + Unfollowed {followToast.name} + {/if} +
+
+{/if} + + diff --git a/src/lib/api.js b/src/lib/api.js index def73f7..a21cac3 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -105,6 +105,12 @@ export const recentAlbums = (limit, minSecs) => invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null }); export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs }); export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist }); +// Followed artists — the home redesign's future "new releases by artists you +// follow" reads the same list, which is why the rows carry name and portrait. +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 }); // The WebView blocks target="_blank"; external links open via the backend. export const openExternal = (url) => invoke("open_external", { url }); export const albumArt = (path) => invoke("album_art", { path }); diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 0a9a6c2..074434e 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -83,6 +83,61 @@ function writePanels() { } } +/* ------------------------------------------------------- followed artists + * Persisted in the library db (followed_artists — id, name, portrait, + * followed_at) so the future home redesign can build "new releases by artists + * you follow" from this list alone. The UI truth is this store; the artist + * page's chip reads it and never keeps its own copy. + */ +export const follows = $state({ + loaded: false, + /** artistId -> true. A plain object, not a Set: $state proxies object + * property changes, which is exactly the granularity the chip needs. */ + ids: {}, +}); + +export async function loadFollows() { + if (follows.loaded) return; + try { + const list = await api.followedArtists(); + follows.ids = Object.fromEntries(list.map((f) => [f.artist_id, true])); + follows.loaded = true; + } catch { + // Backend unavailable — the chip renders unfollowed and stays usable; + // the next call retries because `loaded` never flipped. + } +} + +export const isFollowed = (artistId) => artistId != null && !!follows.ids[artistId]; + +/** Optimistic: the chip flips immediately, the db write follows, and a write + * failure flips it back rather than leaving the UI lying. */ +export async function setFollowed(artist, on) { + const id = artist?.id; + if (id == null) return; + const prev = !!follows.ids[id]; + if (on) follows.ids[id] = true; + else delete follows.ids[id]; + try { + if (on) await api.followArtist(id, artist.name ?? "", artist.picture ?? null); + else await api.unfollowArtist(id); + } catch { + if (prev) follows.ids[id] = true; + else delete follows.ids[id]; + } +} + +/** 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 }); + +export function announceFollow(on, artist) { + followToast.seq += 1; + followToast.on = on; + followToast.name = artist?.name ?? ""; + followToast.portrait = artist?.picture ?? null; +} + export const ui = $state({ view: "home", albumId: null, // selected album in Library view diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index e157d65..cf4de75 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -30,6 +30,10 @@ albumDisambiguator, openCatalogAlbum, openAlbumFromPreviewItem, + loadFollows, + isFollowed, + setFollowed, + announceFollow, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; import { accentFromImage } from "../lib/color.js"; @@ -4316,18 +4320,134 @@ soundcloud: { icon: "brand-soundcloud", label: "SoundCloud", rgb: "208,162,132" }, }; - // TODO(follow): visual-only for now — no persistence. Resets when the - // artist changes so a heart never carries over to the next page. - let followed = $state(false); - let followedArtistId = null; + /* ---- the follow chip (approved 2026-08-04: FB4, leading the row, the + * Encore choreography, the portrait toast) -------------------------------- + * + * Truth lives in the `follows` store — persisted in the library db, read + * by the future home page — and this page keeps only the VISUAL state, + * which deliberately lags it: on follow the db write and the toast fire at + * the click, but the chip walks through the ceremony (+ collapses → three + * bars dance → the check springs in with the label) and only wears its + * on-state at the check's arrival. Unfollow is an undo and skips the show. + * + * fbShown/fbStage drive the markup through Svelte; the ceremony flips them + * on timers. Only the floating notes and the glow are imperative spawns + * (they are transient siblings the template must not own), which is why + * their selectors are :global below — Svelte prunes classes it cannot see + * in the markup, and a pruned rule simply never renders (the .namebox + * lesson). + */ + let fbEl = $state(null); + let fbShown = $state(false); + let fbStage = $state("idle"); // "idle" | "bars" + let fbJustLanded = $state(false); // gates the check/label entrances to the ceremony + // $state because the template reads it (the + wears lead-out while the + // ceremony runs) — a plain let never re-renders in runes mode. + let fbCeremony = $state(false); + let fbTimers = []; + $effect(() => { - const id = page?.artist.id ?? null; - if (id !== followedArtistId) { - followedArtistId = id; - followed = false; + void loadFollows(); + const id = page?.artist?.id; + const on = id != null && isFollowed(id); + if (!fbCeremony) { + fbShown = on; + fbStage = "idle"; + fbJustLanded = false; } }); + // A navigation mid-ceremony must not leak timers into the next artist. + $effect(() => { + void page?.artist?.id; + return () => { + fbTimers.forEach(clearTimeout); + fbTimers = []; + fbCeremony = false; + }; + }); + + /** Follow/Following width change played, never snapped: the label's width + * is animated between its measured sizes and the chip grows with it. */ + function fbMorphLabel(mutate) { + const label = fbEl?.querySelector(".fb-label"); + const w0 = label ? label.getBoundingClientRect().width : null; + mutate(); + if (label == null || w0 == null || reducedMotion()) return; + void tick().then(() => { + const now = fbEl?.querySelector(".fb-label"); + if (!now) return; + const w1 = now.getBoundingClientRect().width; + if (Math.abs(w1 - w0) > 1) { + now.animate( + [{ width: `${w0}px` }, { width: `${w1}px` }], + { duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, + ); + } + }); + } + + /** The ceremony's floating notes — rendered by the template while + * fbCeremony holds, positioned from the lead slot at click time. Their + * animations end holding opacity 0 (fill: both) well before the 1800ms + * teardown unmounts them, so nothing ever pops off mid-fade. */ + let fbNotes = $state([]); + + function toggleFollow() { + const artist = page?.artist; + if (!artist || fbCeremony) return; + if (fbShown) { + // Unfollow: an undo. The shrink still morphs; nothing celebrates. + void setFollowed(artist, false); + announceFollow(false, artist); + fbMorphLabel(() => { + fbShown = false; + fbJustLanded = false; + }); + return; + } + // Persist and toast at the CLICK — the ceremony is presentation, and a + // user who navigates away mid-show must still have followed. + void setFollowed(artist, true); + announceFollow(true, artist); + if (reducedMotion()) { + fbMorphLabel(() => (fbShown = true)); + return; + } + fbCeremony = true; + // One note per beat, rising from the bars: positions taken from the + // lead slot NOW, rendered by the template for as long as the ceremony + // holds. Their flights end at 1710ms (delay 760 + 950) holding opacity + // 0, so the 1800ms teardown only ever removes what has already faded — + // the mock paid twice to learn that removing a note any earlier + // cancels its animation mid-fade. + const lead = fbEl.querySelector(".fb-lead"); + const lr = lead.getBoundingClientRect(); + const br = fbEl.getBoundingClientRect(); + const lx = lr.left - br.left + lr.width / 2; + fbNotes = [0, 1, 2].map((i) => ({ + cx: lx + (i - 1) * 4, + sway: i % 2 ? -7 : 7, + delay: 240 + i * 260, + glyph: i === 1 ? "♫" : "♪", + })); + fbTimers = [ + setTimeout(() => (fbStage = "bars"), 170), + setTimeout(() => { + fbJustLanded = true; + fbMorphLabel(() => { + fbStage = "idle"; + fbShown = true; + }); + }, 1070), + setTimeout(() => { + fbNotes = []; + fbJustLanded = false; + fbCeremony = false; + }, 1800), + ]; + } + // First→latest release year of the released discography ("years active"). // A release within the last two calendar years reads as "present". const activeYears = $derived.by(() => { @@ -4632,73 +4752,79 @@ its stat is empty rather than showing a zero. --> {#if !page.art_preview}
- {#if releasedCount > 0} - {releasedCount} releases - {/if} - {#if page.artist.fans} - {fmtFans(page.artist.fans)} - {/if} - {#if activeYears} - {activeYears} - {/if} - {#if libraryOwnedCount > 0 && releasedCount > 0} - - {libraryOwnedCount}/{releasedCount} - - - {/if} - {#if artistDisk.bytes > 0} - - {fmtBytes(artistDisk.bytes)} - {#if artistDisk.perAlbum.length} - - {#each artistDisk.perAlbum as row (row.title)} - - {row.title} - {fmtBytes(row.bytes)} - - {/each} + + + {#if releasedCount > 0} + {releasedCount} releases {/if} - {#if playStats.seconds >= 60} - - {fmtPlayTime(playStats.seconds)} - + {#if page.artist.fans} + {fmtFans(page.artist.fans)} {/if} - {#if playStats.last} - - {fmtLastPlayed(playStats.last)} - + {#if activeYears} + {activeYears} {/if} - {#if playStats.topTitle && playStats.topCount >= 2} - - {playStats.topTitle} · {playStats.topCount}× - - {/if} - {#if page.top_genre} - - {page.top_genre} - - {/if} - - + {#if page.socials?.length}