follow: the artist page's heart becomes a real thing, stored and celebrated

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.
This commit is contained in:
KeelrLeek committed 2026-08-04 14:43:27 -05:00
1 parent 5786269592
commit 520e2ad4e4
8 files changed
+763 -197

No files matched your search

+6
View File
@@ -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 });
+55
View File
@@ -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