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}