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:
vicrodh committed 2026-07-22 22:18:59 -06:00
1 parent a181fb7aa9
commit 6adb33fcef
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;
}
}
}
}
+18 -2
View File
@@ -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) ============
+52 -5
View File
@@ -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]));
}
+11
View File
@@ -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();