feat(settings): Recommendations tab in the Blacklist Manager
Manage the reco-scoped Not-interested dismissals: a third tab in Settings > Blacklist lists dismissed artists (search-filtered like the other axes, count badge, empty + no-results states) with a per-row undo that removes the entry from the reco_dismiss store — the artist becomes eligible for the Recommendations rails again on their next paint. Row click reuses the existing artist-select navigation. No bulk Clear-All on this tab (undo is per-row); the modal stays scoped to the blacklist axes. km
This commit is contained in:
4 files changed
+296
-10
No files matched your search
@@ -245,6 +245,96 @@ component AlbumRow inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// One reco-dismissed artist row (the Recommendations tab). Lighter than
|
||||
// ArtistRow: the dismissal store carries no timestamp/notes, and the avatar
|
||||
// is the generic user glyph (NOT blind-eye — this is not the blacklist).
|
||||
component DismissedRow inherits Rectangle {
|
||||
in property <string> artist-name;
|
||||
property <bool> hovered: ta.has-hover || rm-ta.has-hover;
|
||||
callback select();
|
||||
callback remove();
|
||||
callback hover-changed(bool);
|
||||
height: 64px;
|
||||
border-radius: Radius.md;
|
||||
background: root.hovered ? Theme.surface-elevated : Theme.surface-card;
|
||||
|
||||
changed hovered => {
|
||||
root.hover-changed(self.hovered);
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
spacing: 12px;
|
||||
|
||||
// Clickable artist-info region — opens the artist page.
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.select();
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/user.svg");
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: root.artist-name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-row undo (optimistic, no confirm — mirrors the blacklist rows).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: Radius.sm;
|
||||
background: rm-ta.has-hover ? Theme.danger-bg : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: rm-ta.has-hover ? Theme.danger : Theme.text-muted;
|
||||
}
|
||||
rm-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component BlacklistManagerView inherits Rectangle {
|
||||
background: ShellState.app-background-active ? transparent : Theme.surface-main;
|
||||
|
||||
@@ -347,6 +437,28 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recommendations tab (reco-scoped "Not interested" dismissals).
|
||||
Rectangle {
|
||||
width: reco-lbl.preferred-width + 28px;
|
||||
border-radius: Radius.sm;
|
||||
background: BlacklistState.active-tab == 2
|
||||
? Theme.surface-card
|
||||
: (reco-ta.has-hover ? Theme.surface-hover : transparent);
|
||||
reco-lbl := Text {
|
||||
text: @tr("Recommendations");
|
||||
color: BlacklistState.active-tab == 2 ? Theme.text-primary : Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
font-weight: Typography.medium;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
reco-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
BlacklistActions.set-tab(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,7 +468,9 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
vertical-stretch: 0;
|
||||
text: BlacklistState.active-tab == 0
|
||||
? @tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.")
|
||||
: @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.");
|
||||
: BlacklistState.active-tab == 1
|
||||
? @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.")
|
||||
: @tr("Dismissed artists only leave your Recommendations — they still appear in search and everywhere else.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
@@ -473,7 +587,9 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
}
|
||||
|
||||
// Clear-All — only when the active tab has something to clear.
|
||||
if (BlacklistState.active-tab == 0 ? BlacklistState.count : BlacklistState.album-count) > 0: Rectangle {
|
||||
// The Recommendations tab has no bulk clear (undo is per-row).
|
||||
if (BlacklistState.active-tab == 0 ? BlacklistState.count
|
||||
: BlacklistState.active-tab == 1 ? BlacklistState.album-count : 0) > 0: Rectangle {
|
||||
width: clear-all-row.preferred-width + 28px;
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
@@ -519,7 +635,9 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
Text {
|
||||
text: BlacklistState.active-tab == 0
|
||||
? @tr("{} artists", BlacklistState.count)
|
||||
: @tr("{} albums", BlacklistState.album-count);
|
||||
: BlacklistState.active-tab == 1
|
||||
? @tr("{} albums", BlacklistState.album-count)
|
||||
: @tr("{} artists", BlacklistState.dismissed-count);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: right;
|
||||
@@ -774,6 +892,100 @@ export component BlacklistManagerView inherits Rectangle {
|
||||
hover-active: root.list-hover-rows > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recommendations tab branches (reco-scoped dismissals) -----
|
||||
// 2c) Empty (nothing dismissed).
|
||||
if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-count == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/thumbs-down.svg");
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
tint: Theme.text-muted;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No dismissed artists");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.heading;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("To stop seeing an artist in Recommendations, open its card menu and choose Not interested.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3c) No search results (dismissed).
|
||||
if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-count > 0 && BlacklistState.dismissed-items.length == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 12px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
tint: Theme.text-muted;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No results for \"{}\"", BlacklistState.search-query);
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.heading;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4c) The dismissed-artists list (per-row undo).
|
||||
if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-items.length > 0: Rectangle {
|
||||
dismissed-flick := Flickable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
viewport-width: self.width;
|
||||
viewport-height: dismissed-content.preferred-height;
|
||||
dismissed-content := VerticalLayout {
|
||||
width: parent.width - 18px;
|
||||
spacing: 4px;
|
||||
for item in BlacklistState.dismissed-items: DismissedRow {
|
||||
artist-name: item.artist-name;
|
||||
select => {
|
||||
BlacklistActions.artist-select(item.artist-id);
|
||||
}
|
||||
remove => {
|
||||
BlacklistActions.remove-dismissed(item.artist-id);
|
||||
}
|
||||
hover-changed(h) => {
|
||||
root.list-hover-rows = h ? root.list-hover-rows + 1 : Math.max(0, root.list-hover-rows - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: dismissed-flick.viewport-height;
|
||||
visible-height: dismissed-flick.height;
|
||||
viewport-y <=> dismissed-flick.viewport-y;
|
||||
auto-hide: true;
|
||||
hover-active: root.list-hover-rows > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2462,6 +2462,16 @@ export struct BlacklistedAlbumItem {
|
||||
has-notes: bool,
|
||||
}
|
||||
|
||||
// One reco-dismissed artist ("Not interested"). Reco-SCOPED — NOT the
|
||||
// blacklist: the artist only leaves the Recommendations rails. The store
|
||||
// carries no timestamp/notes, so the row is just the name (+ a generic
|
||||
// avatar); removal is the undo.
|
||||
export struct DismissedArtistItem {
|
||||
// Numeric Qobuz artist id (same int pass-through as BlacklistedArtistItem).
|
||||
artist-id: int,
|
||||
artist-name: string,
|
||||
}
|
||||
|
||||
export global BlacklistState {
|
||||
// The CURRENTLY-VISIBLE rows (already search-filtered by the controller).
|
||||
in property <[BlacklistedArtistItem]> items: [];
|
||||
@@ -2481,7 +2491,11 @@ export global BlacklistState {
|
||||
in property <[BlacklistedAlbumItem]> album-items: [];
|
||||
// FULL blocked-album count (drives the Albums tab badge + empty state).
|
||||
in property <int> album-count: 0;
|
||||
// Active manager tab: 0 = Artists, 1 = Albums.
|
||||
// --- Reco-dismissal axis ("Not interested" — the Recommendations tab) ---
|
||||
in property <[DismissedArtistItem]> dismissed-items: [];
|
||||
// FULL dismissed count (drives the tab badge + empty state).
|
||||
in property <int> dismissed-count: 0;
|
||||
// Active manager tab: 0 = Artists, 1 = Albums, 2 = Recommendations.
|
||||
in property <int> active-tab: 0;
|
||||
}
|
||||
|
||||
@@ -2498,12 +2512,14 @@ export global BlacklistActions {
|
||||
callback search-changed(string /* query */);
|
||||
|
||||
// --- Album axis ---
|
||||
callback set-tab(int /* 0=artists, 1=albums */);
|
||||
callback set-tab(int /* 0=artists, 1=albums, 2=recommendations */);
|
||||
callback album-select(string /* album id */);
|
||||
callback remove-album(string /* album id */);
|
||||
callback clear-all-albums();
|
||||
// Block an album from a context menu (id + display fields for the manager).
|
||||
callback block-album(string /* id */, string /* title */, string /* artist */, string /* cover-url */);
|
||||
// --- Reco-dismissal axis ---
|
||||
callback remove-dismissed(int /* artist id */);
|
||||
}
|
||||
|
||||
// === My QBZ — Mixtapes & Collections (read-only index grids) ============
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! `BlacklistState`, applies search-as-you-type filtering controller-side, and
|
||||
//! runs the toggle / remove / clear mutations against the
|
||||
//! `crate::artist_blacklist` wrapper (the same fail-open singleton the artist
|
||||
//! toggle in T9 mutates).
|
||||
//! toggle in T9 mutates). A third "Recommendations" tab (active-tab 2) lists
|
||||
//! the reco-SCOPED "Not interested" dismissals from `crate::reco_dismiss` —
|
||||
//! NOT the blacklist — with a per-row undo.
|
||||
//!
|
||||
//! Mirrors `crate::offline_manager`'s shape: a `load` entry point invoked on
|
||||
//! navigation + an action set wired in `main.rs`. There is no change-notify on
|
||||
@@ -29,7 +31,7 @@ use chrono::{DateTime, Utc};
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
|
||||
use crate::{AppWindow, BlacklistState, BlacklistedAlbumItem, BlacklistedArtistItem};
|
||||
use crate::{AppWindow, BlacklistState, BlacklistedAlbumItem, BlacklistedArtistItem, DismissedArtistItem};
|
||||
|
||||
/// Shared image cache for resolving blocked-album cover thumbnails (the artist
|
||||
/// tab has no covers; the album tab does). Set once during startup wiring.
|
||||
@@ -137,16 +139,41 @@ fn build_album_items() -> (Vec<BlacklistedAlbumItem>, i32, Vec<ArtworkJob>) {
|
||||
(items, count, jobs)
|
||||
}
|
||||
|
||||
/// Push the filtered items + full count + enabled flag + query into Slint (both
|
||||
/// axes). Album covers resolve asynchronously via the shared image cache.
|
||||
/// Build the visible (filtered) dismissed-artist items — the "Not interested"
|
||||
/// reco-scoped list — from the store snapshot, applying the current query
|
||||
/// (name match, same rule as the artist axis). `count` carries the FULL list
|
||||
/// length for the tab badge + empty/no-results split.
|
||||
fn build_dismissed_items() -> (Vec<DismissedArtistItem>, i32) {
|
||||
let all = crate::reco_dismiss::list();
|
||||
let count = all.len() as i32;
|
||||
let needle = current_query().trim().to_lowercase();
|
||||
|
||||
let items: Vec<DismissedArtistItem> = all
|
||||
.into_iter()
|
||||
.filter(|a| needle.is_empty() || a.name.to_lowercase().contains(&needle))
|
||||
.map(|a| DismissedArtistItem {
|
||||
// Same int pass-through as the blacklist artist id.
|
||||
artist_id: a.artist_id as i32,
|
||||
artist_name: a.name.into(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
(items, count)
|
||||
}
|
||||
|
||||
/// Push the filtered items + full count + enabled flag + query into Slint (all
|
||||
/// three axes). Album covers resolve asynchronously via the shared image cache.
|
||||
fn push(w: &AppWindow) {
|
||||
let (items, count) = build_items();
|
||||
let (album_items, album_count, jobs) = build_album_items();
|
||||
let (dismissed_items, dismissed_count) = build_dismissed_items();
|
||||
let st = w.global::<BlacklistState>();
|
||||
st.set_items(ModelRc::new(VecModel::from(items)));
|
||||
st.set_count(count);
|
||||
st.set_album_items(ModelRc::new(VecModel::from(album_items)));
|
||||
st.set_album_count(album_count);
|
||||
st.set_dismissed_items(ModelRc::new(VecModel::from(dismissed_items)));
|
||||
st.set_dismissed_count(dismissed_count);
|
||||
st.set_enabled(crate::artist_blacklist::is_enabled());
|
||||
st.set_search_query(SharedString::from(current_query()));
|
||||
// Kick off cover loads (best-effort; needs the cache + a weak handle).
|
||||
@@ -236,7 +263,7 @@ pub fn clear_all(w: &AppWindow) {
|
||||
|
||||
// --- Album axis actions -------------------------------------------------
|
||||
|
||||
/// Switch the manager's active tab (0 = Artists, 1 = Albums).
|
||||
/// Switch the manager's active tab (0 = Artists, 1 = Albums, 2 = Recommendations).
|
||||
pub fn set_tab(w: &AppWindow, tab: i32) {
|
||||
w.global::<BlacklistState>().set_active_tab(tab);
|
||||
}
|
||||
@@ -303,3 +330,23 @@ pub fn clear_all_albums(w: &AppWindow) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Reco-dismissal axis actions -----------------------------------------
|
||||
|
||||
/// Undo one "Not interested" dismissal (optimistic — the re-push drops the row
|
||||
/// immediately) + toast. The artist becomes eligible for the Recommendations
|
||||
/// rails again on their next paint (the §B filter reads the store).
|
||||
pub fn remove_dismissed(w: &AppWindow, artist_id: i32) {
|
||||
// Capture the name before removing, for the toast (falls back to the
|
||||
// generic "Artist" for rows persisted without a resolved name).
|
||||
let name = crate::reco_dismiss::list()
|
||||
.into_iter()
|
||||
.find(|a| a.artist_id == artist_id as u64)
|
||||
.map(|a| a.name)
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| qbz_i18n::t("Artist"));
|
||||
crate::reco_dismiss::remove(artist_id as u64);
|
||||
push(w);
|
||||
crate::toast::success(w, qbz_i18n::t_args("{} restored to Recommendations", &[&name]));
|
||||
}
|
||||
@@ -16576,6 +16576,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- Reco-dismissal callbacks (the Recommendations tab) ---
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<BlacklistActions>()
|
||||
.on_remove_dismissed(move |id| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
blacklist_manager::remove_dismissed(&w, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let bl_runtime_b = app_runtime.clone();
|
||||
|
||||
Reference in new issue
Block a user