fix(slint): favorite state on album cards + un-favorite in place

AlbumCardItem gains is-favorite (filled accent heart on the card +
"Remove from favorites" in the list-row menu) — favorites albums set it
true so the heart shows its state, which it never did before.

Un-favoriting from the favorites list (track heart or album card heart)
now removes the item: the row/card fades out (new `removing` field drives
a 250ms opacity animation on TrackRow / AlbumCard / AlbumListRow), the
favorite is removed on the server (+ fav_cache for tracks), and the row
is dropped from both the full and visible models after the fade. All
TrackItem / AlbumCardItem initializers carry the new fields.

cc
This commit is contained in:
vicrodh committed 2026-05-27 06:56:26 -06:00
1 parent 57bace3e1d
commit 66a9f9da22
14 files changed
+178 -6

No files matched your search

+1
View File
@@ -256,6 +256,7 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
is_favorite: crate::fav_cache::is_favorite(&track.id),
artist_id: track.artist_id.into(),
album_id: album_id.clone(),
removing: false,
})
.collect();
+1
View File
@@ -502,6 +502,7 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
is_favorite: crate::fav_cache::is_favorite(&track.id),
artist_id: track.artist_id.into(),
album_id: track.album_id.into(),
removing: false,
})
.collect();
let release_sections: Vec<DiscoverSection> = data
+72 -1
View File
@@ -337,6 +337,7 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
is_favorite: true,
artist_id: t.artist_id.into(),
album_id: t.album_id.into(),
removing: false,
})
.collect();
// `tracks` is the full set the artwork pipeline targets;
@@ -349,7 +350,15 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
state.set_tracks_search("".into());
}
FavData::Albums { items, total } => {
let cards: Vec<AlbumCardItem> = items.into_iter().map(to_item).collect();
// Everything in the Albums tab is a favorite -> filled heart.
let cards: Vec<AlbumCardItem> = items
.into_iter()
.map(|c| {
let mut it = to_item(c);
it.is_favorite = true;
it
})
.collect();
// `albums` is the full set (artwork target); `albums-visible`
// (what the grid/list renders) shares it until a search/sort
// forks it, so artwork stays live.
@@ -495,6 +504,68 @@ pub fn random_visible_album(window: &AppWindow) -> Option<String> {
model.row_data(idx).map(|a| a.id.to_string())
}
// ---- Un-favorite in place: fade (set `removing`) then remove -----------
/// Flag the matching track row(s) as removing so they fade out.
pub fn mark_track_removing(window: &AppWindow, id: &str) {
let state = window.global::<FavoritesState>();
for model in [state.get_tracks_visible(), state.get_tracks()] {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == id && !item.removing {
item.removing = true;
model.set_row_data(i, item);
}
}
}
}
}
/// Remove the track row from both the rendered + full models (after fade).
pub fn remove_track_row(window: &AppWindow, id: &str) {
let state = window.global::<FavoritesState>();
for model in [state.get_tracks_visible(), state.get_tracks()] {
if let Some(vm) = model.as_any().downcast_ref::<VecModel<TrackItem>>() {
for i in 0..vm.row_count() {
if vm.row_data(i).map(|t| t.id == id).unwrap_or(false) {
vm.remove(i);
break;
}
}
}
}
}
/// Flag the matching album card(s) as removing so they fade out.
pub fn mark_album_removing(window: &AppWindow, id: &str) {
let state = window.global::<FavoritesState>();
for model in [state.get_albums_visible(), state.get_albums()] {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == id && !item.removing {
item.removing = true;
model.set_row_data(i, item);
}
}
}
}
}
/// Remove the album card from both the rendered + full models (after fade).
pub fn remove_album_row(window: &AppWindow, id: &str) {
let state = window.global::<FavoritesState>();
for model in [state.get_albums_visible(), state.get_albums()] {
if let Some(vm) = model.as_any().downcast_ref::<VecModel<AlbumCardItem>>() {
for i in 0..vm.row_count() {
if vm.row_data(i).map(|a| a.id == id).unwrap_or(false) {
vm.remove(i);
break;
}
}
}
}
}
// ---- Tracks multi-select (mirrors playlist.rs) -------------------------
// Selection lives on TrackItem.selected in the rendered `tracks-visible`
// model (which shares `tracks` when no search filter is active).
+2
View File
@@ -440,6 +440,8 @@ pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
quality_detail: card.quality_detail.into(),
track_count: card.track_count.into(),
plain_year: card.plain_year.into(),
is_favorite: false,
removing: false,
}
}
+1
View File
@@ -761,6 +761,7 @@ fn top_track_to_item(t: &TopTrack) -> TrackItem {
is_favorite: crate::fav_cache::is_favorite(&t.id),
artist_id: t.artist_id.clone().into(),
album_id: t.album_id.clone().into(),
removing: false,
}
}
+60
View File
@@ -4313,6 +4313,66 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Un-favorite a track from the favorites list: fade the row, remove
// the favorite on the server, then drop the row after the fade.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<FavoritesActions>()
.on_unfavorite_track(move |id| {
let Some(w) = weak.upgrade() else {
return;
};
favorites::mark_track_removing(&w, &id);
if let Ok(tid) = id.parse::<u64>() {
crate::fav_cache::set(tid, false);
}
let id_srv = id.to_string();
let runtime = runtime.clone();
handle.spawn(async move {
if let Err(e) = runtime.core().remove_favorite("track", &id_srv).await {
log::error!("[qbz-slint] unfavorite track {id_srv} failed: {e}");
}
});
let weak2 = weak.clone();
let id_rm = id.to_string();
slint::Timer::single_shot(std::time::Duration::from_millis(280), move || {
if let Some(w) = weak2.upgrade() {
favorites::remove_track_row(&w, &id_rm);
}
});
});
}
{
// Un-favorite an album from the favorites list (same fade + remove).
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<FavoritesActions>()
.on_unfavorite_album(move |id| {
let Some(w) = weak.upgrade() else {
return;
};
favorites::mark_album_removing(&w, &id);
let id_srv = id.to_string();
let runtime = runtime.clone();
handle.spawn(async move {
if let Err(e) = runtime.core().remove_favorite("album", &id_srv).await {
log::error!("[qbz-slint] unfavorite album {id_srv} failed: {e}");
}
});
let weak2 = weak.clone();
let id_rm = id.to_string();
slint::Timer::single_shot(std::time::Duration::from_millis(280), move || {
if let Some(w) = weak2.upgrade() {
favorites::remove_album_row(&w, &id_rm);
}
});
});
}
{
// Local search over the loaded favorite tracks (title / artist /
// album), re-deriving the rendered list.
+1
View File
@@ -174,6 +174,7 @@ fn to_item(track: &Track) -> TrackItem {
.map(|a| a.id.clone())
.unwrap_or_default()
.into(),
removing: false,
}
}
+1
View File
@@ -381,6 +381,7 @@ fn to_item(track: &Track) -> TrackItem {
.map(|a| a.id.clone())
.unwrap_or_default()
.into(),
removing: false,
}
}
+1
View File
@@ -371,6 +371,7 @@ fn track_item(row: TrackRowData) -> TrackItem {
is_favorite,
artist_id: row.artist_id.into(),
album_id: row.album_id.into(),
removing: false,
}
}
+11 -2
View File
@@ -27,6 +27,7 @@ component OverlayButton inherits Rectangle {
in property <bool> primary: false;
in property <length> diameter: 36px;
in property <length> icon-size: 16px;
in property <bool> active: false;
out property <bool> hovered: ta.has-hover;
callback clicked();
@@ -45,7 +46,9 @@ component OverlayButton inherits Rectangle {
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.primary ? #000000 : #ffffff;
tint: root.active
? Theme.accent
: (root.primary ? #000000 : #ffffff);
}
ta := TouchArea {
@@ -58,6 +61,9 @@ component OverlayButton inherits Rectangle {
export component AlbumCard inherits Rectangle {
in property <AlbumCardItem> album;
// Fade out when the card is being removed (favorites un-favorite).
opacity: root.album.removing ? 0.0 : 1.0;
animate opacity { duration: 250ms; }
callback clicked(string);
// kind ("album") , album id, action ("play" | "queue" | "favorite").
callback media-action(string, string, string);
@@ -153,7 +159,10 @@ export component AlbumCard inherits Rectangle {
VerticalLayout {
alignment: center;
fav-btn := OverlayButton {
icon: @image-url("../assets/icons/heart.svg");
icon: root.album.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
active: root.album.is-favorite;
clicked => {
root.media-action("album", root.album.id, "favorite");
}
@@ -465,6 +465,9 @@ export component FavoritesView inherits Rectangle {
FavoritesActions.open-album(id);
} else if (kind == "artist" && action == "open") {
FavoritesActions.open-artist(id);
} else if (action == "favorite") {
// In the favorites list, un-favorite + fade out.
FavoritesActions.unfavorite-track(id);
} else {
FavoritesActions.track-action(id, action);
}
@@ -578,7 +581,12 @@ export component FavoritesView inherits Rectangle {
root.open-artist(id);
}
media-action(kind, id, action) => {
FavoritesActions.album-action(id, action);
if (action == "favorite") {
// In the favorites list, un-favorite + fade out.
FavoritesActions.unfavorite-album(id);
} else {
FavoritesActions.album-action(id, action);
}
}
}
}
@@ -133,6 +133,9 @@ export component AlbumListHeader inherits Rectangle {
export component AlbumListRow inherits Rectangle {
in property <AlbumCardItem> album;
// Fade out when the row is being removed (favorites un-favorite).
opacity: root.album.removing ? 0.0 : 1.0;
animate opacity { duration: 250ms; }
// Row index — drives the even/odd zebra striping (coherent with TrackRow).
in property <int> index;
// Optional leading rank ("#1" etc) for ranked lists (Most Streamed).
@@ -366,8 +369,12 @@ export component AlbumListRow inherits Rectangle {
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to favorites");
icon: root.album.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
label: root.album.is-favorite
? @tr("Remove from favorites")
: @tr("Add to favorites");
clicked => {
root.media-action("album", root.album.id, "favorite");
}
@@ -25,6 +25,9 @@ import { TrackContextMenu } from "TrackContextMenu.slint";
export component TrackRow inherits Rectangle {
in property <TrackItem> track;
// Fade out when the row is being removed (favorites un-favorite).
opacity: root.track.removing ? 0.0 : 1.0;
animate opacity { duration: 250ms; }
in property <int> index;
in property <bool> show-artwork: true;
in property <bool> show-album: false;
+6
View File
@@ -19,6 +19,8 @@ export struct AlbumCardItem {
ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | ""
artwork-url: string,
artwork: image, // empty until the artwork pipeline resolves it
is-favorite: bool, // filled accent heart on the card when favorited
removing: bool, // fading out (un-favorited in the favorites view)
// --- List-row extras (AlbumListRow; ignored by the grid AlbumCard) ----
release-type: string, // "Album" | "EP" | "Single" | "Live" | "Compilation"
source: string, // "qobuz" | "local" | "plex" | "" — hideable column
@@ -238,6 +240,8 @@ export struct TrackItem {
// album view, both via the media-action bubble channel.
artist-id: string,
album-id: string,
// Fading out (un-favorited in the favorites view) before removal.
removing: bool,
}
// Album detail screen state.
@@ -1491,6 +1495,8 @@ export global FavoritesActions {
callback albums-search(string /* query */);
callback albums-set-sort(string /* sort key */);
callback albums-shuffle();
callback unfavorite-track(string /* track id */);
callback unfavorite-album(string /* album id */);
}
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened