fix(discover): correct artist follow state in the Home/ForYou Pinned carousel

The pinned artist card's follow chip was seeded at build time from
FAV_ARTISTS — an in-memory-only set that was always empty at that moment
(shell entry warmed tracks/albums/awards, never artists; init_for_user
disk-seeded everything but artists) — and nothing re-seeded the pinned
model when the follow set later arrived. Every pinned artist showed
not-followed, and the toggle read the same empty set, so a second click
could genuinely unfollow an artist.

Wire artists into the existing disk lifecycle (the qbz-app store already
had the table + CRUD): init_for_user seeds from disk, set_all_artists
syncs to disk, set_artist mirrors add/remove. Add the shell-entry warm
favorite_artist_ids (skipped offline) and re-seed already-built pinned
artist rows when it lands (model walk, no rebuild/flicker).

km
This commit is contained in:
vicrodh committed 2026-07-19 17:34:48 -06:00
1 parent 92c59db0be
commit cd4a0494a2
2 files changed
+87 -6

No files matched your search

+46 -6
View File
@@ -36,10 +36,11 @@ static FAV_ALBUMS: LazyLock<RwLock<HashSet<String>>> =
static FAV_AWARDS: LazyLock<RwLock<HashSet<String>>> =
LazyLock::new(|| RwLock::new(HashSet::new()));
/// Process-wide set of the user's followed ARTIST ids (u64). IN-MEMORY ONLY
/// (no per-user disk store): the search + artist-page surfaces re-seed it from
/// `favorite_artist_ids` on load, so the follow heart toggle always has the
/// current state without a per-click network hit.
/// Process-wide set of the user's followed ARTIST ids (u64). Same disk-first
/// + network-refresh lifecycle as the album set (seeded from the per-user
/// store in [`init_for_user`], refreshed by the shell-entry warm and the
/// search/artist-page loaders) — the Pinned carousel's artist follow chip
/// reads it at build time, so it must be correct from first paint and offline.
static FAV_ARTISTS: LazyLock<RwLock<HashSet<u64>>> =
LazyLock::new(|| RwLock::new(HashSet::new()));
@@ -102,6 +103,22 @@ pub fn init_for_user(base_dir: &Path) {
}
Err(e) => log::warn!("[qbz-slint] favorites cache award disk seed failed: {e}"),
}
match store.get_favorite_artist_ids() {
Ok(ids) => {
let set: HashSet<u64> = ids
.into_iter()
.filter_map(|id| u64::try_from(id).ok())
.collect();
log::info!(
"[qbz-slint] favorites cache: {} artist ids seeded from disk",
set.len()
);
if let Ok(mut guard) = FAV_ARTISTS.write() {
*guard = set;
}
}
Err(e) => log::warn!("[qbz-slint] favorites cache artist disk seed failed: {e}"),
}
if let Ok(mut guard) = STORE.lock() {
*guard = Some(store);
}
@@ -255,14 +272,25 @@ pub fn is_artist_favorite(artist_id: u64) -> bool {
.unwrap_or(false)
}
/// Replace the followed-artist set with a freshly-fetched id list (in-memory).
/// Replace the followed-artist set with a freshly-fetched id list and mirror
/// it to the per-user store (full replace — same lifecycle as the album set).
/// Blocking disk write; call off the UI thread.
pub fn set_all_artists(ids: HashSet<u64>) {
if let Ok(guard) = STORE.lock() {
if let Some(store) = guard.as_ref() {
let disk: Vec<i64> = ids.iter().map(|&id| id as i64).collect();
if let Err(e) = store.sync_favorite_artists(&disk) {
log::warn!("[qbz-slint] favorites cache artist sync failed: {e}");
}
}
}
if let Ok(mut guard) = FAV_ARTISTS.write() {
*guard = ids;
}
}
/// Insert / remove a single artist id (optimistic follow toggle).
/// Insert / remove a single artist id (optimistic follow toggle) and mirror
/// the change to the per-user store so the follow survives a restart.
pub fn set_artist(artist_id: u64, favorite: bool) {
if let Ok(mut guard) = FAV_ARTISTS.write() {
if favorite {
@@ -271,6 +299,18 @@ pub fn set_artist(artist_id: u64, favorite: bool) {
guard.remove(&artist_id);
}
}
if let Ok(guard) = STORE.lock() {
if let Some(store) = guard.as_ref() {
let res = if favorite {
store.add_favorite_artist(artist_id as i64)
} else {
store.remove_favorite_artist(artist_id as i64)
};
if let Err(e) = res {
log::warn!("[qbz-slint] favorites cache artist disk update failed: {e}");
}
}
}
}
// ==================== Followed awards ====================
+41
View File
@@ -451,6 +451,47 @@ async fn enter_shell(
});
}
// Same for followed ARTISTS — the Pinned carousel's artist follow chip
// seeds from fav_cache at build time (its only build-time consumer), and
// the pinned model is built BEFORE this warm lands: re-seed any already-
// built artist rows once the fresh set arrives (walk > rebuild_pinned: no
// model swap, no artwork-job re-dispatch, no flicker).
{
let runtime = runtime.clone();
let weak = weak.clone();
tokio::spawn(async move {
if crate::offline_mode::engine().is_offline() {
return;
}
match runtime.core().favorite_artist_ids().await {
Ok(ids) => {
let _ =
tokio::task::spawn_blocking(move || fav_cache::set_all_artists(ids)).await;
let _ = weak.upgrade_in_event_loop(move |w| {
let pm = w.global::<PinnedState>().get_items();
for i in 0..pm.row_count() {
if let Some(mut it) = pm.row_data(i) {
if it.kind == "artist" {
let following = it
.artist
.id
.parse::<u64>()
.map(|id| fav_cache::is_artist_favorite(id))
.unwrap_or(false);
if it.artist.following != following {
it.artist.following = following;
pm.set_row_data(i, it);
}
}
}
}
});
}
Err(e) => log::warn!("[qbz-slint] favorite artists warm failed: {e}"),
}
});
}
// Load Audio + Playback settings into the Settings page in the
// background — store reads and device enumeration are blocking.
spawn_settings_snapshot_load(runtime.clone(), weak.clone(), settings_ctx.clone());