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:
11 files changed
+741
-178
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */);
|
||||
|
||||
@@ -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(§ion.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(§ion.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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user