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:
14 files changed
+178
-6
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -174,6 +174,7 @@ fn to_item(track: &Track) -> TrackItem {
|
||||
.map(|a| a.id.clone())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
removing: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -381,6 +381,7 @@ fn to_item(track: &Track) -> TrackItem {
|
||||
.map(|a| a.id.clone())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
removing: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user