feat(qt): port the Blacklist manager for artists and albums

The port had a 67-line settings row showing two counters and a header saying
the Manage chevron was "NOT shipped — this port has no Blacklist Manager view
for it to open". The Slint has 1,114 lines of manager plus the consumer surfaces
that make the feature mean anything.

- src/artist_blacklist.rs is a 1:1 port of the reference's per-user lifecycle,
  including the fail-open asymmetry (is_enabled reads TRUE when there is no
  session, everything else false/empty) and the byte-identical NO_SESSION_ERR.
  src/reco_dismiss_qt.rs carries the reco-dismissal store. Both are bound in
  ALL THREE session-activation paths — the offline one included, because the
  reference records that Tauri never bound it offline and blacklisted artists
  leaked into offline surfaces — and torn down on logout, or the next account
  inherits them.
- The manager view: three tabs (bordered, ADR-008 — no pills), search, sort,
  the enable toggle, the disabled warning banner, the four body branches per
  tab in their load-bearing order, windowed lists, and the clear-all confirm.
  Reached from Settings only, exactly like the Slint; no sidebar entry.
- Every one of the ten store mutations publishes TWICE — its own document and
  publish_settings() — because the Settings counters live in a different
  document and nav_qt::back() re-runs no per-view load. Without it,
  Manage -> unblock -> Back showed pre-mutation counters. The Slint gets this
  free from a shared global.
- The search field is driven imperatively, not by a binding: QbzLineEdit's X
  used to destroy the binding, after which the box went blank while the list
  stayed filtered.
- Consumer surfaces, which is where the feature actually lives: the album card
  and album list row gained "Block this album", the artist card gained the
  3-arg "Not interested", the album header menu gained Block/Unblock swapping
  on state, and the artist page gained the Show/Blacklist flip plus the amber
  hidden banner with its "Show artist" button. album_qt and artist_qt now seed
  isAlbumBlocked / isBlacklisted at document build time — without the seed an
  already-blocked album offered "Block this album" and the first click
  UN-blocked it, the same lie the pin glyph told before isPinned existed.
- search_qt passed &Default::default() for both blacklist snapshots, so nothing
  was filtered anywhere and a greyed row still played. It now passes the real
  snapshots at both call sites.
- The 17 toasts the spec had dropped for want of a toast host are wired: that
  premise stopped being true when toast_qt landed, and a Clear All that failed
  with NO_SESSION_ERR was completely silent. Their msgids are in all eight
  catalogues; the five album-axis ones existed only in es and nl.

Also fixed in passing, pre-existing on this branch and unrelated to the
blacklist: ArtistView's library tab read `libTracks` unqualified from a child
of the object that declares it, which QML resolves against the component root,
not the enclosing object — so the "Tracks" heading threw a ReferenceError and
never rendered. Found by forcing the artist route in the offscreen smoke, which
no earlier gate did.

cc
This commit is contained in:
vicrodh committed 2026-07-29 21:24:12 -06:00
1 parent acf23abc9b
commit 494596616e
26 files changed
+3393 -132

No files matched your search

+30
View File
@@ -34,6 +34,17 @@ pub mod qbz_bridge {
// One JSON document (settings_qt.rs SettingsDoc: audio + playback
// row states, select option lists, output-device groups).
#[qproperty(QString, settings_json)]
// The ACTIVE Settings sub-section (0 Audio … 8 Flatpak/Snap — the
// display order is documented in settings/SettingsView.qml's header).
//
// Bridge state rather than QML state, and that is load-bearing: the
// content Loader UNMOUNTS SettingsView when the user navigates away,
// which destroys a local `property int section` along with it. The
// Blacklist panel's "Manage" chevron does exactly that — it opens the
// manager view — so on Back the user landed on Audio instead of
// Blacklist. This is Slint's `SettingsState.section`, which is a global
// for the same reason (SettingsView.slint:121-200).
#[qproperty(i32, settings_section)]
// --- Search (phase 15) ---------------------------------------------
// Cortinilla (live dropdown): open/loading flags, ONE JSON payload
@@ -91,6 +102,15 @@ pub mod qbz_bridge {
/// Free-text rows (Qobuz Connect device name).
#[qinvokable]
fn settings_string(self: Pin<&mut QbzBridge>, key: QString, value: QString);
/// Settings sub-navigation: switch the active section.
///
/// The rows MUST call this rather than assigning `section` in QML.
/// `section` is a BINDING onto `settingsSection`, and in QML an
/// imperative assignment silently destroys the binding it lands on — so
/// one stray `root.section = n` would work for exactly one click and
/// then strand the view on stale local state.
#[qinvokable]
fn settings_set_section(self: Pin<&mut QbzBridge>, index: i32);
/// "Reset to defaults" (audio + playback stores, quality pref).
#[qinvokable]
fn settings_reset(self: Pin<&mut QbzBridge>);
@@ -239,6 +259,7 @@ use cxx_qt_lib::QString;
/// storage (as required by cxx-qt's Default-constructed qobjects).
pub struct QbzBridgeRust {
settings_json: QString,
settings_section: i32,
cortinilla_open: bool,
cortinilla_loading: bool,
cortinilla_json: QString,
@@ -255,6 +276,8 @@ impl Default for QbzBridgeRust {
fn default() -> Self {
Self {
settings_json: QString::from("{}"),
// 0 = Audio, the section Settings opens on.
settings_section: 0,
cortinilla_open: false,
cortinilla_loading: false,
cortinilla_json: QString::from("{}"),
@@ -302,6 +325,13 @@ impl qbz_bridge::QbzBridge {
crate::settings_string(key.to_string(), value.to_string());
}
/// Purely bridge-local state — no crate handler, nothing to persist. The
/// Slint global is not persisted either: Settings always opens on Audio,
/// the section only has to survive a Loader unmount WITHIN a session.
pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) {
self.as_mut().set_settings_section(index);
}
pub fn settings_reset(self: Pin<&mut Self>) {
crate::settings_reset();
}