fix(slint): spinner on home/album/artist + dedupe only in All-tab carousel

Home, Album and Artist views now show the rotating LoadingSpinner (same
as search) above a label while the initial load runs, replacing the
bare Loading… text.

The top-result-artist dedupe moved from map_search_all into apply_search
and replace_category: the full artists list is kept in SearchState.artists
(used by the Artists tab grid), and a separate artists-carousel drops the
duplicate next to the Most-popular hero on the All tab.

cc
This commit is contained in:
vicrodh committed 2026-05-19 21:00:40 -06:00
1 parent e2c0d812fe
commit 6d8a2e1fff
6 files changed
+92 -33

No files matched your search

+32 -18
View File
@@ -268,20 +268,17 @@ pub fn map_search_all(
results: SearchAllResults,
favorite_artists: &HashSet<u64>,
) -> SearchData {
let mut artists: Vec<ArtistRow> = results
let artists: Vec<ArtistRow> = results
.artists
.items
.iter()
.map(|a| map_artist(a, favorite_artists.contains(&a.id)))
.collect();
let most_popular = map_most_popular(results.most_popular, favorite_artists);
// When the top-result is an artist and is also the first entry in the
// artists list, drop the duplicate so the carousel does not repeat it.
if let MostPopularRow::Artist(mp) = &most_popular {
if artists.first().map_or(false, |a| a.id == mp.id) {
artists.remove(0);
}
}
// Dedupe used to drop the top-result artist from the artists list
// here, but the Artists tab does not show the Most-popular hero —
// it should keep the artist. The dedupe now lives at `apply_search`
// where the carousel-only `artists_carousel` is built.
SearchData {
query: query.to_string(),
albums_total: results.albums.total,
@@ -394,10 +391,23 @@ pub fn apply_search(window: &AppWindow, data: SearchData) {
let artists: Vec<SlimItem> = data.artists.into_iter().map(artist_item).collect();
let playlists: Vec<SearchPlaylistItem> =
data.playlists.into_iter().map(playlist_item).collect();
// Carousel variant of the artists list — drops the first entry when
// it equals the most-popular hero, so the All tab does not duplicate
// the Top result alongside the carousel.
let mp_id = if let MostPopularRow::Artist(ref mp) = data.most_popular {
Some(mp.id.clone())
} else {
None
};
let artists_carousel: Vec<SlimItem> = match (mp_id, artists.first()) {
(Some(id), Some(first)) if first.id == id.as_str() => artists[1..].to_vec(),
_ => artists.clone(),
};
state.set_albums(ModelRc::new(VecModel::from(albums)));
state.set_tracks(ModelRc::new(VecModel::from(tracks)));
state.set_artists(ModelRc::new(VecModel::from(artists)));
state.set_artists_carousel(ModelRc::new(VecModel::from(artists_carousel)));
state.set_playlists(ModelRc::new(VecModel::from(playlists)));
state.set_albums_total(data.albums_total as i32);
@@ -639,18 +649,22 @@ pub fn replace_category(window: &AppWindow, more: MoreRows) {
let items: Vec<SearchTrackItem> = rows.into_iter().map(track_item).collect();
state.set_tracks(ModelRc::new(VecModel::from(items)));
}
MoreRows::Artists(mut rows) => {
// Re-apply the top-result dedupe — if the most-popular hero
// is an artist and is the first entry in the new list, drop
// it so the carousel does not show the duplicate.
if state.get_most_popular_kind().as_str() == "artist" {
let mp_id = state.get_most_popular_artist().id;
if rows.first().map_or(false, |r| r.id == mp_id.as_str()) {
rows.remove(0);
}
}
MoreRows::Artists(rows) => {
// Rebuild both lists: the Artists tab keeps every result; the
// All-tab carousel drops the duplicate next to the Most-popular
// hero.
let items: Vec<SlimItem> = rows.into_iter().map(artist_item).collect();
let mp_id = if state.get_most_popular_kind().as_str() == "artist" {
Some(state.get_most_popular_artist().id)
} else {
None
};
let carousel: Vec<SlimItem> = match (mp_id, items.first()) {
(Some(id), Some(first)) if first.id == id.as_str() => items[1..].to_vec(),
_ => items.clone(),
};
state.set_artists(ModelRc::new(VecModel::from(items)));
state.set_artists_carousel(ModelRc::new(VecModel::from(carousel)));
}
MoreRows::Playlists(rows) => {
let items: Vec<SearchPlaylistItem> = rows.into_iter().map(playlist_item).collect();
+17 -4
View File
@@ -16,6 +16,7 @@ import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
component CircleAction inherits Rectangle {
@@ -528,10 +529,22 @@ export component AlbumPageView inherits Rectangle {
spacing: 0px;
alignment: start;
if AlbumState.loading && AlbumState.tracks.length == 0: Text {
text: @tr("Loading album...");
color: Theme.text-muted;
font-size: Typography.body;
if AlbumState.loading && AlbumState.tracks.length == 0: Rectangle {
height: 280px;
VerticalLayout {
alignment: center;
spacing: 18px;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
Text {
text: @tr("Loading album…");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
}
}
}
// Toolbar — album quality badge and track search.
@@ -14,6 +14,7 @@ import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { TrackContextMenu } from "../primitives/TrackContextMenu.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { Carousel } from "../discover/Carousel.slint";
import { NavButtons } from "../shell/NavButtons.slint";
@@ -297,10 +298,22 @@ export component ArtistPageView inherits Rectangle {
Rectangle { height: 36px; }
if ArtistState.loading && ArtistState.top-tracks.length == 0: Text {
text: @tr("Loading artist...");
color: Theme.text-muted;
font-size: Typography.body;
if ArtistState.loading && ArtistState.top-tracks.length == 0: Rectangle {
height: 280px;
VerticalLayout {
alignment: center;
spacing: 18px;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
Text {
text: @tr("Loading artist…");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
}
}
}
// --- Popular tracks ----------------------------------------------
+17 -4
View File
@@ -13,6 +13,7 @@ import { Carousel } from "Carousel.slint";
import { GridSection } from "GridSection.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
// Header + hint shown for a recently-played module while it has no data
// yet (the play history fills once the playback session records plays).
@@ -217,10 +218,22 @@ export component HomeView inherits Rectangle {
spacing: 40px;
alignment: start;
if HomeState.loading && HomeState.sections.length == 0: Text {
text: @tr("Loading your music...");
color: Theme.text-muted;
font-size: 14px;
if HomeState.loading && HomeState.sections.length == 0: Rectangle {
height: 320px;
VerticalLayout {
alignment: center;
spacing: 18px;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
Text {
text: @tr("Loading your music…");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
}
}
}
for section in HomeState.sections: Carousel {
@@ -489,11 +489,13 @@ export component SearchResultsView inherits Rectangle {
}
}
// Artists carousel — fills the rest of the row.
if SearchState.artists.length > 0: ArtistCarousel {
// Artists carousel — fills the rest of the row. Uses
// the carousel-only list so the Most-popular hero is
// not duplicated next to it.
if SearchState.artists-carousel.length > 0: ArtistCarousel {
horizontal-stretch: 1;
title: @tr("Artists");
artists: SearchState.artists;
artists: SearchState.artists-carousel;
show-view-all: true;
artist-clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
+4
View File
@@ -372,6 +372,10 @@ export global SearchState {
in property <[AlbumCardItem]> albums: [];
in property <[SearchTrackItem]> tracks: [];
in property <[SlimItem]> artists: [];
// Carousel-only variant of artists — skips the top-result artist when
// it is also the first list entry, so the All-tab carousel does not
// duplicate the Most-popular hero. The Artists tab uses `artists`.
in property <[SlimItem]> artists-carousel: [];
in property <[SearchPlaylistItem]> playlists: [];
in property <int> albums-total: 0;
in property <int> tracks-total: 0;