feat(library): ownership-aware playlist cards, Library All genre/sort, live fav/follow sweep

PlaylistCard now reflects ownership: owned playlists show favorite only;
foreign Qobuz playlists show follow/unfollow on the overlay plus follow +
copy in the context menu. Added is-owned/is-following/is-copied to
SearchPlaylistItem, seeded from the favorites owned/followed split and
owner.id == current_user_id elsewhere. Wired the actions id-scoped across
both routing families via shared helpers (copy/follow/favorite), so a card
toggles ITS playlist instead of the open detail.

Library "All" toolbar: genre filter (own "library-all" context over the
mixed feed; albums + tracks carry a genre, non-genre kinds hide when a genre
is selected) and PlaylistView-style sort menu (re-select the active field to
flip asc/desc). Tooltips on the four source toggles.

Live favorite/follow sweep across every card surface that was missed:
album favorite now reaches the Pinned carousel, the Recently Played "view
all" page and the artist "In library" grid; artist follow reaches Home Top
Artists, label-page artists and Pinned; new set_playlist_row_following flips
the follow chip on every playlist card (incl. Pinned) after subscribe.

cc
This commit is contained in:
vicrodh committed 2026-07-19 02:44:43 -06:00
1 parent 89d08c27b8
commit e81ed911f8
11 files changed
+741 -178

No files matched your search

+44 -4
View File
@@ -106,12 +106,26 @@ export component PlaylistCard inherits Rectangle {
opacity: root.overlay-on ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
// Owned playlist → favorite (heart). Foreign Qobuz playlist →
// follow / unfollow (the overlay's primary action, mirroring the
// playlist header). Always one element named `fav-btn` so its
// hover keeps feeding `overlay-on`.
VerticalLayout {
alignment: center;
fav-btn := OverlayButton {
icon: @image-url("../assets/icons/heart.svg");
icon: root.playlist.is-owned
? @image-url("../assets/icons/heart.svg")
: (root.playlist.is-following
? @image-url("../assets/icons/check.svg")
: @image-url("../assets/icons/user-plus.svg"));
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
if (root.playlist.is-owned) {
root.media-action("playlist", root.playlist.id, "favorite");
} else if (root.playlist.is-following) {
root.media-action("playlist", root.playlist.id, "unfollow");
} else {
root.media-action("playlist", root.playlist.id, "follow");
}
}
}
}
@@ -168,11 +182,18 @@ export component PlaylistCard inherits Rectangle {
}
}
// Owned playlists get the queueing actions + favorite; foreign
// Qobuz playlists get queueing + follow/unfollow + copy (hidden
// once copied). Height tracks the visible item count (10px chrome
// + 33px/row) so the panel never clips or over-reserves.
playlist-menu := PopupWindow {
property <int> item-count: root.playlist.is-owned
? 4
: (root.playlist.is-copied ? 4 : 5);
x: 26px;
y: 56px;
width: 196px;
height: 109px;
height: 10px + self.item-count * 33px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
@@ -195,13 +216,32 @@ export component PlaylistCard inherits Rectangle {
root.media-action("playlist", root.playlist.id, "queue");
}
}
ContextMenuItem {
if root.playlist.is-owned: ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to Library");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
if !root.playlist.is-owned: ContextMenuItem {
icon: root.playlist.is-following
? @image-url("../assets/icons/check.svg")
: @image-url("../assets/icons/user-plus.svg");
label: root.playlist.is-following
? @tr("Unfollow on Qobuz")
: @tr("Follow on Qobuz");
clicked => {
root.media-action("playlist", root.playlist.id,
root.playlist.is-following ? "unfollow" : "follow");
}
}
if !root.playlist.is-owned && !root.playlist.is-copied: ContextMenuItem {
icon: @image-url("../assets/icons/copy.svg");
label: @tr("Copy to your library");
clicked => {
root.media-action("playlist", root.playlist.id, "copy");
}
}
}
}
}
+124 -16
View File
@@ -177,6 +177,10 @@ component SidepanelArtistRow inherits Rectangle {
// Genre-filter trigger for the favorites toolbars — opens the shared
// popup in the "favorites" context. Accent fill + "N genres" when active.
component FavGenreButton inherits Rectangle {
// Which shared genre-filter context this button edits ("favorites" for the
// favorites tabs, "library-all" for the mixed Library feed). The popup is
// shared; set-context switches whose selection it shows.
in property <string> context: "favorites";
property <bool> active: GenreFilterState.selected-count > 0;
width: genre-row.preferred-width;
height: 30px;
@@ -212,12 +216,55 @@ component FavGenreButton inherits Rectangle {
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
GenreFilterActions.set-context("favorites");
GenreFilterActions.set-context(root.context);
GenreFilterState.open = !GenreFilterState.open;
}
}
}
// One row of the Library "All" sort menu — mirrors PlaylistView's SortOption:
// the active field shows its direction chevron (asc/desc) and re-selecting it
// toggles direction.
component LibrarySortOption inherits Rectangle {
in property <string> label;
in property <bool> active: false;
in property <bool> asc: true;
callback chosen();
height: 30px;
border-radius: 5px;
background: lso-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 10px;
padding-right: 8px;
spacing: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
alignment: center;
if root.active: QbzIcon {
source: root.asc
? @image-url("../assets/icons/chevron-up.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
}
}
}
lso-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.chosen();
}
}
}
// Grid/list segmented toggle. Parameterized so albums + playlists can both
// drive it with their own view-mode state.
component ViewToggle inherits Rectangle {
@@ -650,13 +697,15 @@ export component FavoritesView inherits Rectangle {
}
}
}
// Source switches — Purchases / Favorites / Following.
// Source switches — Purchases / Favorites / Following. Tooltips
// spell out each cryptic icon (owner request).
VerticalLayout {
alignment: center;
ToggleButton {
icon: @image-url("../assets/icons/shopping-bag.svg");
active: LibraryAllState.show-purchases;
sm: true;
tooltip: @tr("Show purchases");
clicked => { LibraryAllActions.toggle-source("purchases"); }
}
}
@@ -666,6 +715,7 @@ export component FavoritesView inherits Rectangle {
icon: @image-url("../assets/icons/heart.svg");
active: LibraryAllState.show-favorites;
sm: true;
tooltip: @tr("Show favorites");
clicked => { LibraryAllActions.toggle-source("favorites"); }
}
}
@@ -675,6 +725,7 @@ export component FavoritesView inherits Rectangle {
icon: @image-url("../assets/icons/user-plus.svg");
active: LibraryAllState.show-following;
sm: true;
tooltip: @tr("Show followed artists & playlists");
clicked => { LibraryAllActions.toggle-source("following"); }
}
}
@@ -685,9 +736,15 @@ export component FavoritesView inherits Rectangle {
icon: @image-url("../assets/icons/hard-drive.svg");
active: LibraryAllState.show-local;
sm: true;
tooltip: @tr("Show local files & Plex");
clicked => { LibraryAllActions.toggle-source("local"); }
}
}
// Filter by genre (shared popup, own "library-all" context).
VerticalLayout {
alignment: center;
FavGenreButton { context: "library-all"; }
}
// Grid / list view toggle.
VerticalLayout {
alignment: center;
@@ -696,22 +753,70 @@ export component FavoritesView inherits Rectangle {
set-view(v) => { LibraryAllActions.set-view(v); }
}
}
// Sort — PlaylistView-style menu: pick a field, re-pick to
// flip direction (chevron shows asc/desc on the active row).
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 150px;
options: [
@tr("Date added"),
@tr("Title"),
@tr("Artist"),
];
sm: true;
current-index: LibraryAllState.sort-by == "date" ? 0
: LibraryAllState.sort-by == "title" ? 1
: 2;
selected(i) => {
LibraryAllActions.set-sort(
i == 0 ? "date" : i == 1 ? "title" : "artist");
all-sort-anchor := Rectangle {
property <string> sort-label:
LibraryAllState.sort-by == "title" ? @tr("Title")
: LibraryAllState.sort-by == "artist" ? @tr("Artist")
: @tr("Date added");
width: all-sort-row.preferred-width;
height: 30px;
border-radius: 6px;
background: all-sort-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
all-sort-row := HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 6px;
Text {
text: @tr("Sort") + ": " + all-sort-anchor.sort-label;
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
}
VerticalLayout {
alignment: center;
QbzIcon {
source: LibraryAllState.sort-asc
? @image-url("../assets/icons/chevron-up.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 12px;
height: 12px;
tint: Theme.text-secondary;
}
}
}
all-sort-ta := TouchArea {
mouse-cursor: pointer;
clicked => { all-sort-menu.show(); }
}
all-sort-menu := PopupWindow {
x: all-sort-anchor.width - 172px;
y: all-sort-anchor.height + 4px;
width: 172px;
height: 110px;
ContextMenu {
LibrarySortOption {
label: @tr("Date added");
active: LibraryAllState.sort-by == "date";
asc: LibraryAllState.sort-asc;
chosen => { LibraryAllActions.set-sort("date"); }
}
LibrarySortOption {
label: @tr("Title");
active: LibraryAllState.sort-by == "title";
asc: LibraryAllState.sort-asc;
chosen => { LibraryAllActions.set-sort("title"); }
}
LibrarySortOption {
label: @tr("Artist");
active: LibraryAllState.sort-by == "artist";
asc: LibraryAllState.sort-asc;
chosen => { LibraryAllActions.set-sort("artist"); }
}
}
}
}
}
@@ -914,6 +1019,9 @@ export component FavoritesView inherits Rectangle {
cover-count: 1,
url1: item.image-url,
cover1: item.image,
is-owned: item.playlist-owned,
is-following: item.playlist-following,
is-copied: item.playlist-copied,
};
body-opens: true;
media-action(kind, id, action) => {
+13 -1
View File
@@ -4,7 +4,7 @@
// button: highlighted when active, surface-elevated on hover.
import { Theme } from "../foundation/semantic-colors.slint";
import { ShellState, AppearanceState } from "../state.slint";
import { ShellState, AppearanceState, TooltipState } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
export component ToggleButton inherits Rectangle {
@@ -12,6 +12,8 @@ export component ToggleButton inherits Rectangle {
in property <bool> active: false;
// Bootstrap-style small variant. Default is the standard size.
in property <bool> sm: false;
// Optional hover tooltip (empty = none), using the global TooltipOverlay.
in property <string> tooltip;
callback clicked();
width: root.sm ? 30px : 34px;
@@ -41,6 +43,16 @@ export component ToggleButton inherits Rectangle {
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover && root.tooltip != "") {
TooltipState.text = root.tooltip;
TooltipState.anchor-x = root.absolute-position.x + root.width / 2;
TooltipState.anchor-y = root.absolute-position.y;
TooltipState.show = true;
} else if (!self.has-hover) {
TooltipState.show = false;
}
}
}
// Keyboard activation: Enter/Space fire the same click. The TouchArea stays
+25 -1
View File
@@ -125,6 +125,16 @@ export struct SearchPlaylistItem {
// on Discover playlist cards (default neutral dark; replaced once the
// cover decodes). Mosaic cards ignore it.
dominant-color: color,
// Ownership signals driving the card's overlay/menu affordances:
// is-owned — the user is the Qobuz owner → favorite (heart) only.
// is-following — the user follows this NON-owned Qobuz playlist → the
// overlay shows "unfollow" (check) instead of "follow".
// is-copied — already copied into the user's own playlists → the
// context-menu "Copy to your library" item is hidden.
// Editorial/foreign playlists default is-owned=false (follow + copy).
is-owned: bool,
is-following: bool,
is-copied: bool,
}
// One entry in the mixed Pinned carousel (Home / For You). `kind` selects
@@ -4943,7 +4953,7 @@ export global FavoritesActions {
callback labels-random();
callback search-playlists(string /* query */);
callback playlists-set-view(string /* grid | list */);
callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite */);
callback playlist-action(string /* playlist id */, string /* play | play-next | queue | share | favorite | follow | unfollow | copy */);
callback search-artists(string /* query */);
callback artists-set-view(string /* grid | sidepanel */);
callback artists-set-group(string /* off | alpha */);
@@ -4987,6 +4997,15 @@ export struct LibraryFeedItem {
removing: bool,
sort-title: string, // lowercased title for the A-Z sort
sort-artist: string, // lowercased artist for the artist sort
// Lowercased genre name for the client-side genre filter (albums + tracks
// carry it; artists/labels/playlists are ""). "" is excluded when a genre
// is selected.
genre: string,
// Playlist ownership (mirrors SearchPlaylistItem) so the mixed-feed
// PlaylistCard shows favorite (owned) vs follow/copy (foreign).
playlist-owned: bool,
playlist-following: bool,
playlist-copied: bool,
}
export global LibraryAllState {
@@ -4994,6 +5013,10 @@ export global LibraryAllState {
in property <[LibraryFeedItem]> items-visible: []; // after search + source + sort
in-out property <string> search: "";
in-out property <string> sort-by: "date"; // "date" | "title" | "artist"
// Sort direction, PlaylistView-style (re-selecting the active field
// toggles it). For "date": false = newest-first (default), true = oldest;
// for "title"/"artist": true = A→Z (default), false = Z→A.
in-out property <bool> sort-asc: false;
in-out property <string> view-mode: "grid"; // "grid" | "list"
// Source-switch filters — the three webplayer toggles. All on = show all.
in-out property <bool> show-purchases: true;
@@ -5009,6 +5032,7 @@ export global LibraryAllState {
export global LibraryAllActions {
callback search(string /* query */);
// Re-selecting the active field toggles direction (PlaylistView pattern).
callback set-sort(string /* date | title | artist */);
callback set-view(string /* grid | list */);
callback toggle-source(string /* purchases | favorites | following | local */);
+8
View File
@@ -899,6 +899,10 @@ fn playlist_to_item(p: &PlaylistSlim) -> SearchPlaylistItem {
cover4: slint::Image::default(),
category: "".into(),
dominant_color: slint::Color::from_argb_u8(0, 0, 0, 0),
// Artist-page playlists are foreign Qobuz playlists → follow + copy.
is_owned: false,
is_following: false,
is_copied: false,
}
}
@@ -1082,6 +1086,8 @@ pub fn set_release_card_favorite(window: &AppWindow, album_id: &str, favorite: b
flip(&section.albums);
}
}
// "In library" grid (the catalog/library header toggle).
flip(&state.get_library_albums());
let mut last = state.get_last_release();
if last.id == album_id && last.is_favorite != favorite {
last.is_favorite = favorite;
@@ -1118,6 +1124,8 @@ pub fn set_release_card_pinned(window: &AppWindow, album_id: &str, pinned: bool)
flip(&section.albums);
}
}
// "In library" grid (the catalog/library header toggle).
flip(&state.get_library_albums());
let mut last = state.get_last_release();
if last.id == album_id && last.is_pinned != pinned {
last.is_pinned = pinned;
+15 -2
View File
@@ -225,12 +225,18 @@ where
.await
.map_err(|e| e.to_string())?;
let uid = crate::library_db::current_user_id();
// Following = non-owned playlists present in get_user_playlists →
// subscribed by definition, so stamp is_following for the card overlay.
let following: Vec<PlaylistRow> = match uid {
Some(uid) => all
.iter()
.filter(|p| p.owner.id != uid)
.cloned()
.map(search::map_playlist)
.map(|p| {
let mut r = search::map_playlist(p);
r.is_following = true;
r
})
.collect(),
None => Vec::new(),
};
@@ -255,8 +261,15 @@ where
continue;
}
if let Some(p) = by_id.get(&fid) {
favorites.push(search::map_playlist((**p).clone()));
// Hearted AND in the user's playlists: owned ones were already
// seeded above, so a non-owned one here is a followed playlist.
let mut r = search::map_playlist((**p).clone());
if !r.is_owned {
r.is_following = true;
}
favorites.push(r);
} else if let Ok(p) = runtime.core().get_playlist(fid).await {
// Hearted but NOT in the user's playlists → not subscribed.
favorites.push(search::map_playlist(p));
}
}
+4
View File
@@ -722,6 +722,10 @@ pub(crate) fn playlist_to_item(p: &PlaylistCardData) -> SearchPlaylistItem {
// pipeline writes the real dominant colour (mirrors immersive::
// dominant_cover_color's own fallback).
dominant_color: slint::Color::from_rgb_u8(30, 30, 34),
// Discover playlists are editorial (foreign Qobuz) → follow + copy.
is_owned: false,
is_following: false,
is_copied: false,
}
}
+4
View File
@@ -954,6 +954,10 @@ fn playlist_to_item(p: &PlaylistSlim) -> SearchPlaylistItem {
// colour treatment is Discover-only).
category: "".into(),
dominant_color: slint::Color::from_argb_u8(0, 0, 0, 0),
// Label-landing playlists are foreign Qobuz playlists → follow + copy.
is_owned: false,
is_following: false,
is_copied: false,
}
}
+90 -9
View File
@@ -20,7 +20,7 @@ use crate::{AppWindow, LibraryAllState, LibraryFeedItem};
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Plain, `Send` feed item produced on the worker thread.
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct Feed {
pub kind: String, // track | album | artist | playlist | label
pub group: String, // favorites | following | purchases
@@ -36,6 +36,14 @@ pub struct Feed {
pub quality_tier: String,
pub quality_detail: String,
pub is_favorite: bool,
/// Genre name (albums + tracks carry one; artists/labels/playlists ""). Feeds
/// the client-side genre filter — "" is excluded when a genre is selected.
pub genre: String,
/// Playlist ownership (only meaningful for kind == "playlist"): owned →
/// favorite affordance; foreign Qobuz → follow + copy.
pub playlist_owned: bool,
pub playlist_following: bool,
pub playlist_copied: bool,
/// Recency proxy in [0.0, 1.0]; 0.0 = most-recently added. Each source list
/// comes back date-desc, so `index / len` interleaves the sources by recency
/// without needing exact per-item timestamps.
@@ -75,9 +83,11 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
quality_tier: t.quality_tier,
quality_detail: t.quality_detail,
is_favorite: true,
genre: t.genre,
added_rank: rank(i, n),
id: t.id,
title: t.title,
..Default::default()
});
}
}
@@ -99,9 +109,11 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
quality_tier: a.quality_tier,
quality_detail: a.quality_detail,
is_favorite: true,
genre: a.genre,
added_rank: rank(i, n),
id: a.id,
title: a.title,
..Default::default()
});
}
}
@@ -128,6 +140,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
added_rank: rank(i, n),
id: ar.id,
title: ar.name,
..Default::default()
});
}
}
@@ -152,6 +165,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
added_rank: rank(i, n),
id: l.id,
title: l.name,
..Default::default()
});
}
}
@@ -164,7 +178,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
{
let n = fav_pl.len();
for (i, p) in fav_pl.into_iter().enumerate() {
let image_url = p.cover_urls.into_iter().next().unwrap_or_default();
let image_url = p.cover_urls.iter().next().cloned().unwrap_or_default();
feed.push(Feed {
kind: "playlist".into(),
group: "favorites".into(),
@@ -178,14 +192,18 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
quality_tier: String::new(),
quality_detail: String::new(),
is_favorite: true,
playlist_owned: p.is_owned,
playlist_following: p.is_following,
playlist_copied: p.is_copied,
added_rank: rank(i, n),
id: p.id,
title: p.title,
..Default::default()
});
}
let n = fol_pl.len();
for (i, p) in fol_pl.into_iter().enumerate() {
let image_url = p.cover_urls.into_iter().next().unwrap_or_default();
let image_url = p.cover_urls.iter().next().cloned().unwrap_or_default();
feed.push(Feed {
kind: "playlist".into(),
group: "following".into(),
@@ -199,9 +217,13 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
quality_tier: String::new(),
quality_detail: String::new(),
is_favorite: false,
playlist_owned: p.is_owned,
playlist_following: p.is_following,
playlist_copied: p.is_copied,
added_rank: rank(i, n),
id: p.id,
title: p.title,
..Default::default()
});
}
}
@@ -213,6 +235,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
for (i, a) in albums.into_iter().enumerate() {
let image_url = a.image.best().cloned().unwrap_or_default();
let tier = if a.hires { "hires" } else { "cd" };
let genre = a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default();
feed.push(Feed {
kind: "album".into(),
group: "purchases".into(),
@@ -226,9 +249,11 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
quality_tier: tier.into(),
quality_detail: String::new(),
is_favorite: false,
genre,
added_rank: rank(i, n),
id: a.id,
title: a.title,
..Default::default()
});
}
let n = tracks.len();
@@ -265,6 +290,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
added_rank: rank(i, n),
id: t.id.to_string(),
title: t.title,
..Default::default()
});
}
}
@@ -291,6 +317,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
added_rank: rank(i, n),
id: lf.id,
title: lf.title,
..Default::default()
});
}
}
@@ -324,6 +351,10 @@ fn to_item(f: &Feed) -> LibraryFeedItem {
removing: false,
sort_title: f.title.to_lowercase().into(),
sort_artist: f.artist.to_lowercase().into(),
genre: f.genre.to_lowercase().into(),
playlist_owned: f.playlist_owned,
playlist_following: f.playlist_following,
playlist_copied: f.playlist_copied,
}
}
@@ -339,8 +370,25 @@ pub fn apply_library_all(window: &AppWindow, feed: Vec<Feed>) {
derive(window);
}
/// Apply search + source-switch + sort over the full model into `items-visible`.
/// Runs on the Slint event loop; Slint never sorts/filters.
/// PlaylistView-style sort toggle: re-selecting the active field flips its
/// direction; a new field resets to that field's natural default ("date"
/// newest-first, "title"/"artist" A→Z). Then re-derive.
pub fn set_sort(window: &AppWindow, field: &str) {
let st = window.global::<LibraryAllState>();
let cur_field = st.get_sort_by().to_string();
let new_asc = if cur_field == field {
!st.get_sort_asc()
} else {
// "date" starts descending (newest first); the others start ascending.
field != "date"
};
st.set_sort_by(field.into());
st.set_sort_asc(new_asc);
derive(window);
}
/// Apply search + source-switch + genre + sort over the full model into
/// `items-visible`. Runs on the Slint event loop; Slint never sorts/filters.
pub fn derive(window: &AppWindow) {
let st = window.global::<LibraryAllState>();
let needle = st.get_search().to_lowercase();
@@ -349,6 +397,15 @@ pub fn derive(window: &AppWindow) {
let show_following = st.get_show_following();
let show_local = st.get_show_local();
let sort_by = st.get_sort_by();
let sort_asc = st.get_sort_asc();
// Shared genre filter (its own "library-all" context). Empty = no filter;
// otherwise an item shows only when its (lowercased) genre matches one of
// the selected genre names — kinds with no genre (artist/label/playlist)
// are excluded, so the feed narrows to the chosen genre's albums + tracks.
let genre_names: Vec<String> = crate::genre_filter::selected_names("library-all")
.into_iter()
.map(|g| g.to_lowercase())
.collect();
let full = st.get_items();
let mut out: Vec<LibraryFeedItem> = Vec::new();
@@ -385,14 +442,38 @@ pub fn derive(window: &AppWindow) {
continue;
}
}
if !genre_names.is_empty() {
let g = item.genre.as_str();
if g.is_empty() || !genre_names.iter().any(|n| g.contains(n.as_str())) {
continue;
}
}
out.push(item);
}
// Canonical ascending order per field, then reverse for the other
// direction. "date" has no key on the item (the model is stored
// newest-first from load), so it uses the inherent order: asc(false) =
// newest-first (default), asc(true) = oldest-first (reversed).
match sort_by.as_str() {
"title" => out.sort_by(|a, b| a.sort_title.as_str().cmp(b.sort_title.as_str())),
"artist" => out.sort_by(|a, b| a.sort_artist.as_str().cmp(b.sort_artist.as_str())),
// "date" keeps the merged recency order from load.
_ => {}
"title" => {
out.sort_by(|a, b| a.sort_title.as_str().cmp(b.sort_title.as_str()));
if !sort_asc {
out.reverse();
}
}
"artist" => {
out.sort_by(|a, b| a.sort_artist.as_str().cmp(b.sort_artist.as_str()));
if !sort_asc {
out.reverse();
}
}
// "date": model order is newest-first; reverse only for oldest-first.
_ => {
if sort_asc {
out.reverse();
}
}
}
st.set_items_visible(ModelRc::new(VecModel::from(out)));
+389 -143
View File
@@ -1649,6 +1649,11 @@ fn set_album_row_favorite(window: &AppWindow, album_id: &str, favorite: bool) {
flip(&favs.get_albums_visible());
flip_sections(&favs.get_albums_grouped());
flip_sections(&favs.get_selected_artist_sections());
// Dedicated "Recently played — view all" page.
flip(&window.global::<RecentAlbumsState>().get_albums());
// Pinned mixed carousel (Home / For You) — the album lives NESTED inside a
// PinnedItem, so the generic `flip` can't reach it.
set_pinned_album_favorite(window, album_id, favorite);
// Now-playing bar "+" flyout — its add/remove-album-to-collection entry
// reads NowPlayingState.album-favorite; flip it when the toggled album is
// the one playing so the label stays honest without a track change.
@@ -1776,6 +1781,9 @@ fn set_album_row_pinned(window: &AppWindow, album_id: &str, pinned: bool) {
flip(&ll.get_folders_visible());
flip_sections(&ll.get_folders_grouped());
flip(&ll.get_artists_selected_albums());
// Dedicated "Recently played — view all" page (the Pinned carousel itself is
// rebuilt wholesale by pinned_section::rebuild_pinned right after a pin).
flip(&window.global::<RecentAlbumsState>().get_albums());
}
/// Playlist counterpart of [`set_album_row_pinned`]: flip the `is-pinned`
@@ -1855,6 +1863,77 @@ fn set_artist_row_pinned(window: &AppWindow, artist_id: &str, pinned: bool) {
}
}
/// Flip the nested album's `is-favorite` on any Pinned carousel row (Home / For
/// You) matching `album_id`. The album lives inside a `PinnedItem`, so neither
/// the `[AlbumCardItem]` nor the `[DiscoverSection]` sweep reaches it.
fn set_pinned_album_favorite(window: &AppWindow, album_id: &str, favorite: bool) {
let pm = window.global::<PinnedState>().get_items();
for i in 0..pm.row_count() {
if let Some(mut it) = pm.row_data(i) {
if it.kind == "album" && it.album.id == album_id && it.album.is_favorite != favorite {
it.album.is_favorite = favorite;
pm.set_row_data(i, it);
}
}
}
}
/// Flip the nested artist's `following` on any Pinned carousel row matching
/// `artist_id`. Twin of [`set_pinned_album_favorite`] for the follow chip.
/// `pub(crate)` so `search::mark_artist_followed` can reach the Pinned model.
pub(crate) fn set_pinned_artist_following(window: &AppWindow, artist_id: &str, following: bool) {
let pm = window.global::<PinnedState>().get_items();
for i in 0..pm.row_count() {
if let Some(mut it) = pm.row_data(i) {
if it.kind == "artist" && it.artist.id == artist_id && it.artist.following != following {
it.artist.following = following;
pm.set_row_data(i, it);
}
}
}
}
/// Flip `is-following` on every visible PLAYLIST card matching `playlist_id`
/// (Home rail, Qobuz Playlists browse, Search, Favorites, label/artist
/// landings, Pinned carousel) so a follow/unfollow from ANY card overlay or
/// menu updates the others live. Playlist twin of [`set_artist_row_pinned`].
fn set_playlist_row_following(window: &AppWindow, playlist_id: &str, following: bool) {
let flip = |model: &slint::ModelRc<SearchPlaylistItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == playlist_id && item.is_following != following {
item.is_following = following;
model.set_row_data(i, item);
}
}
}
};
flip(&window.global::<HomeState>().get_playlists());
flip(&window.global::<SearchState>().get_playlists());
let browse = window.global::<PlaylistBrowseState>();
flip(&browse.get_playlists());
flip(&browse.get_visible());
let favs = window.global::<FavoritesState>();
flip(&favs.get_playlists_favorites());
flip(&favs.get_playlists_following());
flip(&favs.get_playlists_visible());
flip(&window.global::<LabelState>().get_playlists());
flip(&window.global::<ArtistState>().get_playlists());
// Pinned mixed carousel — nested SearchPlaylistItem.
let pm = window.global::<PinnedState>().get_items();
for i in 0..pm.row_count() {
if let Some(mut it) = pm.row_data(i) {
if it.kind == "playlist"
&& it.playlist.id == playlist_id
&& it.playlist.is_following != following
{
it.playlist.is_following = following;
pm.set_row_data(i, it);
}
}
}
}
/// Feed Capa B (intelligent-search ranking) from a RESULTS-PAGE click, but only
/// when the results page is the active view. `on_open_album` / `on_open_artist`
/// / `on_media_action` are global handlers shared by every view (album detail,
@@ -1885,6 +1964,154 @@ fn record_search_interaction(
crate::search_service::record(&query, kind, id, action);
}
/// Copy a Qobuz playlist (by id) into the user's own playlists: fetch the
/// source, create a new owned playlist, add every track, carry the cover over
/// and mark the SOURCE id copied. Shared by the playlist header AND the card
/// menus (Discover / Search / Favorites / Library All). `is_open` = this id is
/// the currently-open detail, so its PlaylistState.is-copied flips too.
fn playlist_copy_by_id(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
pid: u64,
is_open: bool,
) {
handle.spawn(async move {
let source = match runtime.core().get_playlist(pid).await {
Ok(p) => p,
Err(e) => {
log::error!("[qbz-slint] copy playlist {pid}: get source failed: {e}");
crate::toast::error_weak(&weak, qbz_i18n::t("Failed to copy playlist"));
return;
}
};
let track_ids: Vec<u64> = source
.tracks
.as_ref()
.map(|t| t.items.iter().map(|track| track.id).collect())
.unwrap_or_default();
if track_ids.is_empty() {
crate::toast::error_weak(&weak, qbz_i18n::t("Playlist has no tracks to copy"));
return;
}
let attribution = format!(
"\n\n---\nOriginally curated by {} on Qobuz",
source.owner.name
);
let new_description = match source.description {
Some(ref d) if !d.is_empty() => Some(format!("{d}{attribution}")),
_ => Some(attribution.trim_start().to_string()),
};
let new_playlist = match runtime
.core()
.create_playlist(&source.name, new_description.as_deref(), false)
.await
{
Ok(p) => p,
Err(e) => {
log::error!("[qbz-slint] copy playlist {pid}: create failed: {e}");
crate::toast::error_weak(&weak, qbz_i18n::t("Failed to copy playlist"));
return;
}
};
if let Err(e) = runtime
.core()
.add_tracks_to_playlist(new_playlist.id, &track_ids)
.await
{
log::error!("[qbz-slint] copy playlist {pid}: add tracks failed: {e}");
}
if let Some(img) = source.images.as_ref().and_then(|i| i.first()).cloned() {
let new_id = new_playlist.id;
let _ = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
db.update_playlist_artwork(new_id, Some(img.as_str()))
});
})
.await;
}
let _ = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.mark_playlist_copied(pid));
})
.await;
let _ = weak.upgrade_in_event_loop(move |w| {
if is_open {
w.global::<PlaylistState>().set_is_copied(true);
}
crate::toast::success(&w, qbz_i18n::t("Copied to your library"));
});
crate::playback::refresh_sidebar(true);
});
}
/// Follow / unfollow a Qobuz playlist (by id). `is_open` = this id is the
/// currently-open detail (only then do we flip PlaylistState optimistically +
/// revert on error). Shared by the header follow toggle and the card overlay.
fn playlist_set_follow_by_id(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
pid: u64,
follow: bool,
is_open: bool,
) {
if is_open {
let weak_opt = weak.clone();
let _ = weak_opt.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_is_following(follow);
});
}
handle.spawn(async move {
let res = if follow {
runtime.core().subscribe_playlist(pid).await
} else {
runtime.core().unsubscribe_playlist(pid).await
};
if let Err(e) = res {
log::error!("[qbz-slint] playlist {pid} follow={follow} failed: {e}");
if is_open {
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_is_following(!follow);
});
}
} else {
// Live-flip the follow chip on every visible playlist card.
let ids = pid.to_string();
let _ = weak.upgrade_in_event_loop(move |w| {
set_playlist_row_following(&w, &ids, follow);
});
crate::playback::refresh_sidebar(true);
}
});
}
/// Toggle the INTERNAL library-favorite flag of a playlist (by id), reading
/// the authoritative current state from the DB so a card (which can't know it)
/// flips the right way. `is_open` mirrors the open detail's PlaylistState.
fn playlist_toggle_favorite_by_id(
handle: tokio::runtime::Handle,
weak: slint::Weak<AppWindow>,
pid: u64,
is_open: bool,
) {
handle.spawn_blocking(move || {
let currently = crate::library_db::with_db(|db| db.get_favorite_playlist_ids())
.unwrap_or_default()
.contains(&pid);
let new_fav = !currently;
let ok = crate::library_db::with_db(|db| db.set_playlist_favorite(pid, new_fav));
if ok.is_none() {
log::error!("[qbz-slint] toggle playlist {pid} favorite failed");
return;
}
if is_open {
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_is_favorite(new_fav);
});
}
});
}
/// Toggle a track favorite by its REAL Qobuz id: offline guard (read-only
/// hearts, spec 4.3), optimistic flip across the visible rows + the shared
/// fav cache, then the network add/remove with rollback on failure. Shared
@@ -3731,8 +3958,10 @@ fn navigate_library_all(
let st = w.global::<LibraryAllState>();
st.set_loading(true);
st.set_load_error("".into());
// The genre popup edits the favorites context on this surface too.
genre_filter::set_context("favorites");
// The mixed feed has its OWN genre-filter context (independent of
// the favorites albums/tracks filter) so the toolbar badge reflects
// this surface's selection on entry.
genre_filter::set_context("library-all");
genre_filter::apply_state(&w);
});
match library_all::load_library_all(&runtime).await {
@@ -12464,158 +12693,83 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
("playlist", "favorite") => {
// Favorite/unfavorite the open playlist. This is an INTERNAL
// qbz library flag (Qobuz /favorite/create does NOT accept
// playlist_ids), mirroring the Favorites-card toggle and
// Tauri's v2_playlist_set_favorite. Surfaced in
// Favorites>Playlists + Library>Playlists.
// Internal qbz library flag (Qobuz /favorite/create rejects
// playlist_ids). id-scoped: a CARD toggles ITS playlist, not
// the open one; the DB read picks the direction. `is_open`
// keeps the detail's optimistic heart in sync.
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
if local_playlist::is_local_id(&pid) {
if local_playlist::is_local_id(&id) {
return;
}
let Ok(pid_u64) = pid.parse::<u64>() else {
let Ok(pid) = id.parse::<u64>() else {
return;
};
let was_fav = w.global::<PlaylistState>().get_is_favorite();
let new_fav = !was_fav;
w.global::<PlaylistState>().set_is_favorite(new_fav);
let weak2 = weak.clone();
handle.spawn_blocking(move || {
// with_db flattens the inner Result to Option<()>
// (None = no db OR the write failed) -> revert the
// optimistic heart on failure.
let ok = crate::library_db::with_db(|db| {
db.set_playlist_favorite(pid_u64, new_fav)
});
if ok.is_none() {
log::error!("[qbz-slint] toggle playlist favorite failed");
let _ = weak2.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_is_favorite(was_fav);
});
}
});
let is_open = w.global::<PlaylistState>().get_id().to_string() == id;
playlist_toggle_favorite_by_id(handle.clone(), weak.clone(), pid, is_open);
}
}
("playlist", "copy") => {
// Copy a Qobuz playlist into the user's own playlists
// (create + add every track). INTERNAL — mirrors Tauri's
// (mis-named) v2_subscribe_playlist. The copy is owned, so it
// shows up in Library>Playlists via get_user_playlists.
// (create + add every track). id-scoped so a card copies ITS
// playlist; the detail passes its own id, so behavior is
// unchanged there (is_open flips PlaylistState.is-copied).
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
if local_playlist::is_local_id(&pid) {
if local_playlist::is_local_id(&id) {
return;
}
let Ok(pid_u64) = pid.parse::<u64>() else {
let Ok(pid) = id.parse::<u64>() else {
return;
};
let runtime = runtime.clone();
let weak2 = weak.clone();
handle.spawn(async move {
let source = match runtime.core().get_playlist(pid_u64).await {
Ok(p) => p,
Err(e) => {
log::error!("[qbz-slint] copy playlist: get source failed: {e}");
crate::toast::error_weak(&weak2, qbz_i18n::t("Failed to copy playlist"));
return;
}
};
let track_ids: Vec<u64> = source
.tracks
.as_ref()
.map(|t| t.items.iter().map(|track| track.id).collect())
.unwrap_or_default();
if track_ids.is_empty() {
crate::toast::error_weak(&weak2, qbz_i18n::t("Playlist has no tracks to copy"));
return;
}
let attribution = format!(
"\n\n---\nOriginally curated by {} on Qobuz",
source.owner.name
);
let new_description = match source.description {
Some(ref d) if !d.is_empty() => Some(format!("{d}{attribution}")),
_ => Some(attribution.trim_start().to_string()),
};
let new_playlist = match runtime
.core()
.create_playlist(&source.name, new_description.as_deref(), false)
.await
{
Ok(p) => p,
Err(e) => {
log::error!("[qbz-slint] copy playlist: create failed: {e}");
crate::toast::error_weak(&weak2, qbz_i18n::t("Failed to copy playlist"));
return;
}
};
if let Err(e) = runtime
.core()
.add_tracks_to_playlist(new_playlist.id, &track_ids)
.await
{
log::error!("[qbz-slint] copy playlist: add tracks failed: {e}");
}
// Best-effort: carry over the source artwork.
if let Some(img) =
source.images.as_ref().and_then(|i| i.first()).cloned()
{
let new_id = new_playlist.id;
let _ = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
db.update_playlist_artwork(new_id, Some(img.as_str()))
});
})
.await;
}
// Persist the SOURCE playlist id as "copied" so its
// detail view hides the Copy button on later opens
// (mirrors Tauri's user-scoped `qbz_copied_playlists`).
let _ = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.mark_playlist_copied(pid_u64));
})
.await;
let _ = weak2.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_is_copied(true);
crate::toast::success(&w, qbz_i18n::t("Copied to your library"));
});
crate::playback::refresh_sidebar(true);
});
let is_open = w.global::<PlaylistState>().get_id().to_string() == id;
playlist_copy_by_id(runtime.clone(), weak.clone(), handle.clone(), pid, is_open);
}
}
("playlist", "follow") => {
// Follow/unfollow on Qobuz (subscribe). Appears in the user's
// Qobuz account across clients and in Library>Playlists via
// get_user_playlists (as a non-owned subscribed playlist).
// Follow on Qobuz (subscribe). The DETAIL button emits
// "follow" as a toggle (id == open → flip current state); a
// CARD carries its follow-state and emits follow/unfollow
// explicitly, so a card "follow" always subscribes.
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
if local_playlist::is_local_id(&pid) {
if local_playlist::is_local_id(&id) {
return;
}
let Ok(pid_u64) = pid.parse::<u64>() else {
let Ok(pid) = id.parse::<u64>() else {
return;
};
let was_following = w.global::<PlaylistState>().get_is_following();
let make = !was_following;
w.global::<PlaylistState>().set_is_following(make);
let runtime = runtime.clone();
let weak2 = weak.clone();
handle.spawn(async move {
let res = if make {
runtime.core().subscribe_playlist(pid_u64).await
} else {
runtime.core().unsubscribe_playlist(pid_u64).await
};
if let Err(e) = res {
log::error!("[qbz-slint] toggle playlist follow failed: {e}");
let _ = weak2.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_is_following(was_following);
});
} else {
crate::playback::refresh_sidebar(true);
}
});
let is_open = w.global::<PlaylistState>().get_id().to_string() == id;
let follow = if is_open {
!w.global::<PlaylistState>().get_is_following()
} else {
true
};
playlist_set_follow_by_id(
runtime.clone(),
weak.clone(),
handle.clone(),
pid,
follow,
is_open,
);
}
}
("playlist", "unfollow") => {
// Card-only: unfollow (unsubscribe) the given playlist.
if let Some(w) = weak.upgrade() {
if local_playlist::is_local_id(&id) {
return;
}
let Ok(pid) = id.parse::<u64>() else {
return;
};
let is_open = w.global::<PlaylistState>().get_id().to_string() == id;
playlist_set_follow_by_id(
runtime.clone(),
weak.clone(),
handle.clone(),
pid,
false,
is_open,
);
}
}
("playlist", "select-toggle") => {
@@ -16490,6 +16644,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
};
genre_filter::apply_state(&w);
// Library "All": client-side genre filter over the mixed feed.
if genre_filter::current_context() == "library-all" {
let runtime_f = runtime.clone();
let weak_f = weak.clone();
let image_cache_f = image_cache.clone();
let id_f = id.to_string();
handle.spawn(async move {
if !was_selected {
if let Ok(gid) = id_f.parse::<u64>() {
genre_filter::load_descendants(&runtime_f, gid).await;
}
}
let _ = weak_f.upgrade_in_event_loop(move |w| {
genre_filter::apply_state(&w);
library_all::derive(&w);
let jobs = library_all::artwork_jobs(&w);
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(
jobs,
plex.base_url,
plex.token,
w.as_weak(),
image_cache_f.clone(),
);
});
});
return;
}
// Favorites: client-side genre filter — re-derive the active
// favorites tab instead of re-fetching the discover index.
if genre_filter::current_context() == "favorites" {
@@ -16619,6 +16801,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
};
genre_filter::apply_state(&w);
if genre_filter::current_context() == "library-all" {
library_all::derive(&w);
let jobs = library_all::artwork_jobs(&w);
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(
jobs,
plex.base_url,
plex.token,
w.as_weak(),
image_cache.clone(),
);
return;
}
if genre_filter::current_context() == "favorites" {
if w.global::<FavoritesState>().get_active_tab().as_str() == "albums" {
favorites::derive_albums(&w);
@@ -20873,22 +21068,72 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match action.as_str() {
"share" => share::copy_to_clipboard(share::qobuz_playlist_url(&id)),
"favorite" => {
// Library sub-tab: un-favorite in place (remove the
// local favorite + drop the row). Following sub-tab:
// add to the local Library (per user decision).
let library = w
let Ok(pid) = id.parse::<u64>() else {
return;
};
let in_playlists_tab = w
.global::<FavoritesState>()
.get_playlists_sub_tab()
.get_active_tab()
.to_string()
!= "following";
if let Ok(pid) = id.parse::<u64>() {
== "playlists";
if in_playlists_tab {
// Favorites › Playlists: Library sub-tab un-favorites
// in place (drop the row); Following sub-tab adds to
// the local Library (per user decision).
let library = w
.global::<FavoritesState>()
.get_playlists_sub_tab()
.to_string()
!= "following";
let fav = !library;
handle.spawn_blocking(move || {
crate::library_db::with_db(|db| db.set_playlist_favorite(pid, fav));
});
if library {
favorites::remove_playlist_row(&w, &id);
}
} else {
// Library "All" (mixed feed): authoritative toggle by
// the DB state — a foreign card can't know it, and the
// owned-but-unhearted case must ADD, not remove.
playlist_toggle_favorite_by_id(handle.clone(), weak.clone(), pid, false);
}
if library {
favorites::remove_playlist_row(&w, &id);
}
"follow" => {
if let Ok(pid) = id.parse::<u64>() {
playlist_set_follow_by_id(
runtime.clone(),
weak.clone(),
handle.clone(),
pid,
true,
false,
);
}
}
"unfollow" => {
if let Ok(pid) = id.parse::<u64>() {
playlist_set_follow_by_id(
runtime.clone(),
weak.clone(),
handle.clone(),
pid,
false,
false,
);
// In the Favorites › Playlists Following sub-tab,
// unfollowing removes the row (mirrors un-favorite).
let fs = w.global::<FavoritesState>();
if fs.get_active_tab().to_string() == "playlists"
&& fs.get_playlists_sub_tab().to_string() == "following"
{
favorites::remove_playlist_row(&w, &id);
}
}
}
"copy" => {
if let Ok(pid) = id.parse::<u64>() {
playlist_copy_by_id(runtime.clone(), weak.clone(), handle.clone(), pid, false);
}
}
act => {
@@ -20979,8 +21224,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let image_cache = image_cache.clone();
window.global::<LibraryAllActions>().on_set_sort(move |key| {
if let Some(w) = weak.upgrade() {
w.global::<LibraryAllState>().set_sort_by(key);
library_all::derive(&w);
// Re-selecting the active field toggles asc/desc (PlaylistView
// pattern); a new field resets to its natural direction.
library_all::set_sort(&w, key.as_str());
let jobs = library_all::artwork_jobs(&w);
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
+25 -2
View File
@@ -16,8 +16,8 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{
AlbumCardItem, AppWindow, CortinillaRow as CortinillaRowItem,
CortinillaSection as CortinillaSectionItem, ExternalRecoState, ForYouState, SearchPlaylistItem,
SearchState, SlimItem, TrackItem,
CortinillaSection as CortinillaSectionItem, ExternalRecoState, ForYouState, HomeState,
LabelState, SearchPlaylistItem, SearchState, SlimItem, TrackItem,
};
thread_local! {
@@ -139,6 +139,14 @@ pub struct PlaylistRow {
pub subtitle: String,
/// Up to four distinct cover URLs for the collage.
pub cover_urls: Vec<String>,
/// Ownership signals for the card overlay/menu (owned → favorite; foreign
/// Qobuz → follow + copy). `is_owned` is authoritative (owner.id ==
/// current user); `is_following`/`is_copied` are best-effort per source
/// (favorites seeds `is_following` from the followed split; other list
/// surfaces leave them false — the action still works id-scoped).
pub is_owned: bool,
pub is_following: bool,
pub is_copied: bool,
}
/// The most-popular hero entry.
@@ -356,11 +364,17 @@ pub fn map_playlist(playlist: Playlist) -> PlaylistRow {
subtitle = format!("{} • {}", subtitle, tracks_label);
}
}
let is_owned = crate::library_db::current_user_id()
.map(|uid| uid == playlist.owner.id)
.unwrap_or(false);
PlaylistRow {
id: playlist.id.to_string(),
title: playlist.name,
subtitle,
cover_urls,
is_owned,
is_following: false,
is_copied: false,
}
}
@@ -1278,6 +1292,9 @@ pub(crate) fn playlist_item(row: PlaylistRow) -> SearchPlaylistItem {
// only).
category: "".into(),
dominant_color: slint::Color::from_argb_u8(0, 0, 0, 0),
is_owned: row.is_owned,
is_following: row.is_following,
is_copied: row.is_copied,
}
}
@@ -1498,6 +1515,12 @@ pub fn mark_artist_followed(window: &AppWindow, artist_id: &str, following: bool
set_slim_following(&reco.get_rec_artists_common(), artist_id, following);
set_slim_following(&reco.get_rec_artists_recent(), artist_id, following);
set_slim_following(&reco.get_top_artists(), artist_id, following);
// Home "Top artists" carousel + label-page related artists — walked by the
// pin twin (`set_artist_row_pinned`) but historically missed here.
set_slim_following(&window.global::<HomeState>().get_top_artists(), artist_id, following);
set_slim_following(&window.global::<LabelState>().get_artists(), artist_id, following);
// Pinned mixed carousel (Home / For You) — nested artist SlimItem.
crate::set_pinned_artist_following(window, artist_id, following);
}
/// Flip `following` on the row matching `artist_id` in a `[SlimItem]` model.