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

+35 -11
View File
@@ -9,12 +9,15 @@
// (Open album / Play / Play next / Play later / Add to queue). Right-clicking
// the row opens the same menu at the pointer, like every other ⋯ site.
//
// "Block this album" is absent for the same reason AlbumCard.qml gates it
// off: the Qt bridge exposes the blacklist COUNTERS but no block-album
// invokable, and a row that renders and does nothing is a defect.
// "Block this album" is the sixth entry, live since QbzBlacklist landed
// (primitives/AlbumListRow.slint:434-441). It is pushed conditionally rather
// than sitting in the literal because the .slint gates it on a non-local /
// non-plex source — see the `entries` block.
//
// item contract: home_qt::HomeCard — { id, title, artist, artistId, year,
// qualityTier, qualityDetail, artPath }.
// qualityTier, qualityDetail, artUrl, artPath }. `artUrl` is the REMOTE cover
// url and `artPath` the local file:// cache path; only the former may be
// persisted (src/home_qt.rs:117-121).
import QtQuick
import com.blitzfc.qbz
@@ -65,18 +68,39 @@ Rectangle {
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
// A JS expression, not a literal: the block row is CONDITIONAL. The
// function form also re-evaluates on `trRev`, which the literal
// already did through each `tr(…, trRev)` call.
entries: {
var t = QbzSession.tr
var r = QbzSession.trRev
var m = [
{ "label": t("Open album", r), "icon": "library-big", "action": "open" },
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
]
// AlbumListRow.slint:434-441 gates the row on a non-local/plex
// source. `home_qt::HomeCard` carries NO `source` field, so this
// reads "" and the row shows — correct for these two catalog-only
// surfaces (Discover Browse, Label Releases). Written defensively
// so a producer that starts emitting `source` is honoured without
// a second edit here.
var src = root.item.source || ""
if (src !== "local" && src !== "plex")
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
return m
}
onPicked: function (a) {
var id = root.item.id || ""
if (id === "") return
if (a === "open") QbzAlbum.openAlbum(id)
else if (a === "play") QbzPlayer.playAlbum(id)
// `artUrl`, never `artPath`: the store keeps a denormalized cover
// url and a file:// cache path is dead on any other machine.
else if (a === "block") QbzBlacklist.blockAlbum(id, root.item.title || "",
root.item.artist || "", root.item.artUrl || "")
else QbzPlayer.enqueueAlbum(id, a)
}
}
+84 -1
View File
@@ -155,6 +155,21 @@ Rectangle {
root.setToggleState("album", value)
}
}
// Album-blacklist settle / rollback. `blacklistChanged` carries the state
// the write actually produced — flipped on success, UNCHANGED on failure
// (blacklist_qt.rs `album_toggle`), so this is both the cross-surface walk
// (the manager's row `x`, a card's "Block this album") and the rollback for
// the header menu's optimistic flip below. Same two-arg `{kind}:{id}` shape
// as pinChanged / libraryFavoriteChanged above; a separate Connections
// block only because the signal lives on a different singleton.
Connections {
target: QbzBlacklist
function onBlacklistChanged(key, value) {
var id = (header && header.id) ? header.id : ""
if (id !== "" && key === "album:" + id)
root.setToggleState("blocked", value)
}
}
Component.onCompleted: { syncAlbumState(); dispatchCovers() }
onAlbumChanged: { syncAlbumState(); dispatchCovers() }
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
@@ -944,6 +959,15 @@ Rectangle {
onEnqueueRequested: function (m) {
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
}
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
}
@@ -1085,7 +1109,9 @@ Rectangle {
target: pageFlick
}
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin).
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin + the block
// toggle; the .slint's playlist/mixtape/share/offline rows are the parts
// still out of scope).
QbzContextMenu {
id: albumMenu
menuWidth: 196
@@ -1140,5 +1166,62 @@ Rectangle {
}
}
}
// AlbumContextMenu.slint:153 — a 1px border-subtle separator, then
// the album-blacklist toggle (:157-172). The .slint writes it as two
// `if` arms (Block / Unblock) whose row count is constant; one row
// with a flipping label is the same single row, and it is the shape
// ArtistPageView.slint:561-572 uses for the identical toggle. Own
// Rectangle rather than a sixth Repeater entry because the inline
// delegate above has no separator arm — `{sep:true}` is CardMenu's
// vocabulary, not this hand-rolled menu's (ArtistView.qml's
// overflow menu draws its blacklist row exactly this way).
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Rectangle {
id: ablkRow
width: parent.width
height: 33
radius: 5
// Seeded from the header document, which does NOT carry the
// field yet (album_qt.rs still has to port album.rs:683's
// `set_is_album_blocked` seed — spec 03 F13). Read defensively:
// `undefined` folds to false through toggleState's
// `fallback === true`, so the row is correct the moment the
// seed lands and never throws before then.
readonly property bool blocked: root.toggleState("blocked", header.isAlbumBlocked)
color: ablkArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: "blind-eye"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
width: parent.width - 23
text: ablkRow.blocked
? QbzSession.tr("Unblock album", QbzSession.trRev)
: QbzSession.tr("Block this album", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: ablkArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
albumMenu.close()
// Optimistic flip (main.rs:12835), then the mutation;
// `blacklistChanged` above settles it — or rolls it
// back on a write failure (main.rs:12859).
root.setToggleState("blocked", !ablkRow.blocked)
QbzBlacklist.albumToggle(header.id, header.title,
header.artist, header.artUrl)
}
}
}
}
}
+157 -10
View File
@@ -200,6 +200,28 @@ Rectangle {
localToggles = Object.assign({}, m)
}
// --- Artist blacklist -------------------------------------------------
// ONE source for the two surfaces the .slint drives off the same
// ArtistState.is-blacklisted: the overflow-menu row label
// (ArtistPageView.slint:565-567) and the hidden-artist banner (:595, :600).
// artist_qt.rs does not seed the field yet (spec 03 C5 — main.rs:2653-2659
// does it in the reference), so `artist.isBlacklisted` reads `undefined`
// today and toggleState's `fallback === true` folds that to false; the
// optimistic flip + the `blacklistChanged` settle below make the page
// correct within a visit, and it becomes correct on ENTRY the moment the
// seed lands, with no QML change.
readonly property bool artistBlacklisted: toggleState("artistBlacklist", artist.isBlacklisted)
function toggleBlacklist() {
var aid = artist.id || ""
if (aid === "")
return
// Optimistic flip first (main.rs:12777 `st.set_is_blacklisted(!was)`),
// then the mutation; `blacklistChanged` settles it — or rolls it back
// on a failed write (blacklist_qt.rs `artist_toggle`).
setToggleState("artistBlacklist", !artistBlacklisted)
QbzBlacklist.artistToggle(aid, artist.name || "")
}
readonly property var discoveryRows: {
var out = []
var rows = network.discovery || []
@@ -315,6 +337,22 @@ Rectangle {
root.setToggleState("artistPin", value)
}
}
// Blacklist settle / rollback. `blacklistChanged` carries the state the
// write actually produced — flipped on success, UNCHANGED on failure
// (blacklist_qt.rs `artist_toggle`), which is exactly what main.rs:12799
// does with its rollback branch. Also the cross-surface walk: unblocking
// the same artist from the manager view while this page is mounted moves
// the menu label and drops the banner. Same two-arg `{kind}:{id}` shape as
// the two signals above; its own Connections block only because the signal
// lives on a different singleton.
Connections {
target: QbzBlacklist
function onBlacklistChanged(key, value) {
var aid = (artist && artist.id) ? artist.id : ""
if (aid !== "" && key === "artist:" + aid)
root.setToggleState("artistBlacklist", value)
}
}
Connections {
target: QbzArtist
@@ -1253,6 +1291,95 @@ Rectangle {
}
}
// --- Hidden-artist banner (ArtistPageView.slint:595-660) ------
// Only when the CURRENTLY displayed artist is blacklisted. The page
// stays fully navigable — a direct fetch-by-id is never blocked
// (.slint:595-599) — so this is an unblock affordance, not a lock.
// Sits between the header and the body row, exactly where the
// .slint puts it (after the header block at :594, before the body
// row), with the .slint's own 16px spacer (:600).
// Built inline rather than through controls/WarningBanner.qml: that
// control has no action slot, and this banner's whole point is the
// right-hand "Show artist" button.
Item { visible: root.artistBlacklisted; width: 1; height: 16 }
Rectangle {
id: hiddenBanner
visible: root.artistBlacklisted
width: parent.width - 64
// .slint `height: banner-row.preferred-height` where the row is
// a HorizontalLayout with padding 12 (:602) — so 24 plus the
// tallest child: the 16px glyph, the wrapped copy, or the 28px
// button (:604, :620, :639).
height: visible ? 24 + Math.max(16, bannerCopy.implicitHeight, 28) : 0
radius: 8
// LITERALS, not theme tokens: the .slint hardcodes both
// (:596-599). theme.warningBg / warningBorder are a different
// amber (#fbbf24-based) and would not match the reference.
color: "#eab3081a"
border.width: 1
border.color: "#eab3084d"
// 16x16 blind-eye (:606-611). The .slint tints it with the
// literal #eab308; QbzIcon.tintName is a CLOSED vocabulary of
// names with no #eab308 bake, so "warning" (theme.warning
// #fbbf24) is the nearest available and the only theme-following
// amber — spec 03 C6's default decision.
QbzIcon {
id: bannerGlyph
name: "blind-eye"
width: 16
height: 16
tintName: "warning"
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
}
// "Show artist" button (:634-659): width = label + 20, height
// 28, radius 6, hover fill surface-elevated else transparent,
// label accent -> accentHover on hover, 13 / semibold.
Rectangle {
id: bannerBtn
width: bannerBtnLabel.implicitWidth + 20
height: 28
radius: 6
color: bannerBtnArea.containsMouse ? theme.surfaceElevated : "transparent"
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
Text {
id: bannerBtnLabel
anchors.centerIn: parent
text: QbzSession.tr("Show artist", QbzSession.trRev)
color: bannerBtnArea.containsMouse ? theme.accentHover : theme.accent
font.pixelSize: 13
font.weight: theme.weightSemibold
}
MouseArea {
id: bannerBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Same seam as the menu row (:654-657) — one toggle.
onClicked: root.toggleBlacklist()
}
}
// Copy (:619-627): text-secondary, Typography.legal = 13,
// word-wrap. The 10px gaps on both sides are the .slint's
// HorizontalLayout spacing (:603).
Text {
id: bannerCopy
anchors.left: bannerGlyph.right
anchors.leftMargin: 10
anchors.right: bannerBtn.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("This artist is hidden from discovery", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
wrapMode: Text.WordWrap
}
}
Item { width: 1; height: 20 }
// --- JUMP TO bar ---------------------------------------------
@@ -1632,13 +1759,13 @@ Rectangle {
readonly property var libTracks: libItems.filter(function (x) { return x.kind === "track" })
Text {
visible: libTracks.length > 0
visible: libraryTab.libTracks.length > 0
text: QbzSession.tr("Tracks", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Item { visible: libTracks.length > 0; width: 1; height: 10 }
Item { visible: libraryTab.libTracks.length > 0; width: 1; height: 10 }
Repeater {
model: libraryTab.libTracks
delegate: PopularTrackRow {
@@ -2168,7 +2295,7 @@ Rectangle {
// ArtistPageView.slint:523) — dimmed, no hover, no click,
// and the menu keeps its shape.
model: [
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "stub", "live": false },
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true },
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub", "live": false },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub", "live": false },
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin", "live": true },
@@ -2206,22 +2333,28 @@ Rectangle {
if (modelData.action === "pin") {
root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
} else if (modelData.action === "disco") {
// Discography Builder — ArtistPageView.slint
// :505-511 `media-action("artist", id,
// "build-collection")`. This is the ONLY route
// to it; the nav flyout has no builder entry.
QbzDisco.open(artist.id)
}
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// Blacklist: the Qt bridge exposes the blacklist COUNTERS but no
// artist-block invokable (same gap AlbumCard.qml documents for
// "Block this album"), so the row is dimmed and inert rather than
// closing the menu and doing nothing.
// ArtistPageView.slint:557-572 — the 1px border-subtle separator
// above, then the LAST item, whose label flips
// "Show artist" / "Blacklist artist" on ArtistState.is-blacklisted.
// LIVE since QbzBlacklist landed (`artistToggle(id, name)`); it was
// dimmed-and-inert only while the bridge had no invokable.
Rectangle {
width: parent.width
height: 33
radius: 5
opacity: 0.4
color: "transparent"
color: blkArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
@@ -2229,10 +2362,24 @@ Rectangle {
QbzIcon { name: "blind-eye"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
text: QbzSession.tr("Blacklist artist", QbzSession.trRev)
width: parent.width - 23
text: root.artistBlacklisted
? QbzSession.tr("Show artist", QbzSession.trRev)
: QbzSession.tr("Blacklist artist", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: blkArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
overflowMenu.close()
root.toggleBlacklist()
}
}
}
@@ -0,0 +1,539 @@
// Blacklist Manager — QML port of crates/qbz-ui/ui/settings/
// BlacklistManagerView.slint (1114 lines). A FULL-PAGE content view, not a
// settings subview: the Slint mounts it in AppShell (shell/AppShell.slint:604)
// and it is reached only from the Settings > Blacklist "Manage" button, exactly
// as here (route id "blacklist").
//
// Three tabs (Artists u64 ids · Albums String ids · Recommendations, the
// reco-scoped "Not interested" dismissals), each with the SAME four
// mutually-exclusive body branches in a load-bearing order:
// 1 loading · 2 FULL count == 0 (empty) · 3 FULL count > 0 but the filtered
// list is empty (no results) · 4 the list.
// Branch 2 vs 3 is the whole reason the document's `count` / `albumCount` /
// `dismissedCount` are the FULL list lengths and never the filtered ones
// (blacklist_manager.rs:72, 104, 148).
//
// Everything the view shows comes out of ONE document, QbzBlacklist
// .blacklistJson — filtering, ordering, the date strings and the has-notes
// flag are all Rust-side (src/blacklist_qt.rs), and QML never filters. The tab
// index and the search query live in Rust too (TAB / QUERY), so leaving and
// re-entering the manager keeps both, like Slint's BlacklistState.
//
// Deltas vs the Slint, each one deliberate and stated:
// - NO per-view Back chrome (the Slint's NavButtons row, :351-354). Nav history
// is the global HeaderBar in this port — the same treatment
// settings/SettingsView.qml:24-25 already documents for the identical case.
// ADR-004 is satisfied by the HeaderBar.
// - `UiFocusState.text-input-focused` (:554) is NOT ported: the port has no
// equivalent guard anywhere and no global keybinding layer to guard (neither
// Main.qml nor AppShell.qml declares a Shortcut). It belongs to the
// port-wide hotkey pass; recorded here as its call site.
// - The list is a recycling ListView, not a Flickable over every row
// (TRACK-RULES §6 — a list that can be large is born windowed), so the
// Slint's `list-hover-rows` scrollbar reveal-on-row-hover is dropped:
// QbzScrollBar reveals on scroll + gutter hover/press instead, and a
// per-row hover counter across a recycling delegate is cost for a cosmetic
// cue.
// - No toast host exists in this port, so the reference's failure toasts have
// nowhere to land. Failures log Rust-side and the settled `blacklistChanged`
// republish corrects any optimistic glyph.
//
// Nav re-entry: nav_qt::back()/forward() republish `currentView` and run NO
// per-view load, so this view calls QbzBlacklist.reload() in
// Component.onCompleted to match Slint's reload-on-apply-entry
// (main.rs:3299-3305).
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
// Transparent while the ambient background is active — the frosted content
// panel shows through (HomeView.qml:53 and its twelve siblings).
color: root.ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Round to the AppShell content-frame bezel (Radius.md): QML clips are
// rectangular, so the frame's rounding never reaches the view — the view's
// own fill must round instead. A literal in all 14 painting view roots.
radius: 12
QbzTheme { id: theme }
// ============================== data ==================================
readonly property var doc: {
try {
return JSON.parse(QbzBlacklist.blacklistJson)
} catch (e) {
return ({})
}
}
readonly property int activeTab: root.doc.activeTab || 0
// `!== false`, NOT `=== true`: BlacklistState.enabled defaults to true
// (state.slint:2489), so reading a `{}` first-frame document the other way
// flashes the amber "filtering is disabled" banner on every mount.
readonly property bool filterEnabled: root.doc.enabled !== false
readonly property string searchQuery: root.doc.searchQuery || ""
readonly property int artistCount: root.doc.count || 0
readonly property int albumCount: root.doc.albumCount || 0
readonly property int dismissedCount: root.doc.dismissedCount || 0
/// The FULL count for the active tab — what separates "empty" from
/// "no results", and what the count badge and Clear-All gate read.
readonly property int fullCount: root.activeTab === 0 ? root.artistCount
: root.activeTab === 1 ? root.albumCount : root.dismissedCount
/// The already-filtered rows for the active tab.
readonly property var listRows: root.activeTab === 0 ? (root.doc.artists || [])
: root.activeTab === 1 ? (root.doc.albums || []) : (root.doc.dismissed || [])
/// The four body branches, evaluated in the reference's order.
readonly property int bodyBranch: QbzBlacklist.blacklistLoading ? 1
: root.fullCount === 0 ? 2
: root.listRows.length === 0 ? 3 : 4
Component.onCompleted: QbzBlacklist.reload()
function tabTabs(rev) {
return [
{ "id": "artists", "label": QbzSession.tr("Artists", rev) },
{ "id": "albums", "label": QbzSession.tr("Albums", rev) },
{ "id": "reco", "label": QbzSession.tr("Recommendations", rev) }
]
}
// ------------------------- album cover window -------------------------
// Covers are NEVER in the document: albums[].artPath is the already-cached
// path ("" when unresolved) and the misses are fetched for the VISIBLE
// window only. Arrivals land on the existing QbzLibrary.libraryArtworkReady
// signal keyed by the cover URL and are coalesced into ONE rebind per frame
// — rebinding per arrival is quadratic in the page (the documented
// views/ArtistView.qml:265-288 fix).
property var coverMap: ({})
property var _coverInbox: ({})
Timer {
id: coverFlush
interval: 16
repeat: false
onTriggered: {
var m = Object.assign({}, root.coverMap, root._coverInbox)
root._coverInbox = ({})
// A rebind needs a NEW object reference (same-ref assignment is not
// a change in QML).
root.coverMap = m
}
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
root._coverInbox[key] = path
if (!coverFlush.running) coverFlush.start()
}
}
Timer {
id: coverWindow
interval: 80
repeat: false
onTriggered: root.reportCoverWindow()
}
/// Row pitch is 64 + 4 spacing = 68. Computed arithmetically rather than
/// with indexAt(), which is what the port's other windowed lists do
/// (LibraryView.qml:1474) — the rows are a fixed height, so the two agree.
function reportCoverWindow() {
if (root.activeTab !== 1)
return
var m = root.listRows
if (m.length === 0)
return
var first = Math.max(0, Math.floor(rowList.contentY / 68) - 2)
var last = Math.min(m.length - 1,
Math.ceil((rowList.contentY + rowList.height) / 68) + 2)
var urls = []
for (var i = first; i <= last; i++) {
var u = m[i].coverUrl || ""
if (u !== "" && (m[i].artPath || "") === "" && !root.coverMap[u])
urls.push(u)
}
if (urls.length === 0)
return
QbzBlacklist.artworkWindow(JSON.stringify(urls))
}
// ============================ chrome ==================================
// The Slint outer VerticalLayout is padding 24 / spacing 14 with the body
// at vertical-stretch: 1. QML has no layout here (this port uses none), so
// the fixed head is a Column and the body fills what is left.
Column {
id: head
x: 24
y: 24
width: root.width - 48
spacing: 14
// ---- header (:357-378) -------------------------------------------
Row {
spacing: 12
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "blind-eye"
width: 24
height: 24
tintName: "muted"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Blacklist", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
}
}
// ---- tab bar (:381-464) — ADR-008, no pills ----------------------
// The shared control. Its signal carries the tab ID STRING, not an
// index (QbzTabBar.qml:24, emitted :97), so the handler maps it; a
// handler written for an index passes `undefined` to an i32 invokable
// and every click is a runtime error `cargo check` cannot see.
QbzTabBar {
tabs: root.tabTabs(QbzSession.trRev)
activeId: root.activeTab === 1 ? "albums"
: root.activeTab === 2 ? "reco" : "artists"
onSelected: function (id) {
QbzBlacklist.setTab(id === "artists" ? 0 : id === "albums" ? 1 : 2)
}
}
// ---- description, per tab (:467-477) -----------------------------
Text {
width: parent.width
text: root.activeTab === 0
? QbzSession.tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.", QbzSession.trRev)
: root.activeTab === 1
? QbzSession.tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.", QbzSession.trRev)
: QbzSession.tr("Dismissed artists only leave your Recommendations — they still appear in search and everywhere else.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
// ---- controls row (:480-646) — spacing 12 ------------------------
Item {
width: parent.width
height: 34
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 12
// (a) Enable/Disable toggle (:485-522). The ONLY enable toggle
// in the feature — the Settings row deliberately has none
// (ContentFilteringSettings.slint:13-16).
Rectangle {
width: toggleRow.implicitWidth + 28
height: 34
radius: theme.radiusSm
color: toggleArea.containsMouse ? theme.surfaceHover
: theme.surfaceElevated
Row {
id: toggleRow
x: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: root.filterEnabled ? "eye" : "eye-off"
width: 18
height: 18
tintName: root.filterEnabled ? "accent" : "muted"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.filterEnabled
? QbzSession.tr("Enabled", QbzSession.trRev)
: QbzSession.tr("Disabled", QbzSession.trRev)
color: root.filterEnabled ? theme.accent : theme.textMuted
font.pixelSize: theme.fontLegal
}
}
MouseArea {
id: toggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBlacklist.toggleEnabled()
}
}
// (b) Search (:525-587) — search-as-you-type, NO debounce:
// every edit re-pushes the Rust-filtered rows. The shared
// control's search arm; the width override replaces its
// 240 default.
//
// A PLAIN BINDING on `text`, which is also what re-seeds the box
// on re-entry (the Rust QUERY survives navigation, so the
// last-published document already carries it and the binding
// shows it before `reload()` lands). This used to be an
// imperative `syncSearchField()` because `clearSearch()` wrote
// `root.text = ""` and destroyed exactly this binding on the
// first click of the ×; the control no longer touches `text` at
// all (QbzLineEdit.qml:66-92), so the workaround is gone. Its
// focus-loss re-seed Binding is what keeps the field from
// fighting the per-keystroke republish.
QbzLineEdit {
id: searchField
anchors.verticalCenter: parent.verticalCenter
searchMode: true
width: 280
placeholder: ""
text: root.searchQuery
onEdited: function (v) { QbzBlacklist.searchChanged(v) }
}
// (c) Clear All (:591-627) — only when the ACTIVE tab's FULL
// count > 0, and never on tab 2 (the Recommendations tab has no
// bulk clear; undo is per row). It opens the confirm, it does
// NOT mutate.
//
// controls/SettingsButton.qml was considered and rejected: its
// `width: Math.max(160, …)` floor against the Slint's
// content + 28 (~105px for "Clear All" + a 16px glyph) would
// put a 160px pill next to a 34px toggle, and it always draws a
// 1px border and hovers to surfaceHover where this button is
// borderless and hovers to dangerBg.
Rectangle {
visible: root.activeTab === 0 ? root.artistCount > 0
: root.activeTab === 1 ? root.albumCount > 0 : false
width: clearRow.implicitWidth + 28
height: 34
radius: theme.radiusSm
color: clearArea.containsMouse ? theme.dangerBg
: theme.surfaceElevated
Row {
id: clearRow
x: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "trash-2"
width: 16
height: 16
// NOT "danger" — no such tint exists; `favorite`
// (#ef4444) is the port-wide substitution and
// favorite/trash-2.svg is on the qrc floor.
tintName: "favorite"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Clear All", QbzSession.trRev)
color: theme.danger
font.pixelSize: theme.fontLegal
}
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: clearConfirm.open()
}
}
}
// (d) count badge (:629-645) — the FULL count, right-aligned. Tab 2
// says "artists" too.
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.activeTab === 1
? QbzSession.tr("{} albums", QbzSession.trRev).replace("{}", root.albumCount)
: QbzSession.tr("{} artists", QbzSession.trRev)
.replace("{}", root.activeTab === 0 ? root.artistCount
: root.dismissedCount)
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
}
}
// ---- disabled warning banner (:649-678) --------------------------
// The shared control, variant "warning" (its default). The copy goes in
// `title` because that is the TONE-coloured line (WarningBanner.qml:58);
// `body` is textSecondary, which is wrong for this banner. Accepted
// control deltas: radius 8 vs Radius.md 12, glyph `info` vs
// `circle-alert` (which exists in NO tint dir and is not in MASTERS —
// the substitution WarningBanner.qml:5-8 already documents), semibold
// vs regular, and 10%/30% tone alphas vs warning-bg/warning-border's
// ~15%/35%.
WarningBanner {
visible: !root.filterEnabled
variant: "warning"
title: QbzSession.tr("Blacklist filtering is disabled. Blacklisted artists are shown everywhere until you re-enable it.", QbzSession.trRev)
}
}
// ============================== body ==================================
Item {
id: bodyHost
x: 24
y: head.y + head.height + 14
width: root.width - 48
height: Math.max(0, root.height - y - 24)
// 1) Loading. The Slint stacks the spinner and the label with no
// spacing (two centred HorizontalLayouts in a VerticalLayout).
Column {
visible: root.bodyBranch === 1
anchors.centerIn: parent
spacing: 0
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
// U+2026, not three dots — an exact-byte msgid.
text: QbzSession.tr("Loading blacklist…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
// 2) Empty, per tab (:702-732, :801-831, :898-928). The shared control
// with its four optional metric props set to the Slint's numbers:
// 48px glyph at 0.3 opacity, a MUTED regular-weight title.
QbzEmptyState {
visible: root.bodyBranch === 2
anchors.centerIn: parent
iconName: root.activeTab === 2 ? "thumbs-down" : "blind-eye"
iconSize: 48
iconOpacity: 0.3
titleMuted: true
titleWeight: theme.weightRegular
title: root.activeTab === 0
? QbzSession.tr("No blacklisted artists", QbzSession.trRev)
: root.activeTab === 1
? QbzSession.tr("No blocked albums", QbzSession.trRev)
: QbzSession.tr("No dismissed artists", QbzSession.trRev)
body: root.activeTab === 0
? QbzSession.tr("To hide an artist, go to their page and use the menu.", QbzSession.trRev)
: root.activeTab === 1
? QbzSession.tr("To block an album, open its menu and choose Block this album.", QbzSession.trRev)
: QbzSession.tr("To stop seeing an artist in Recommendations, open its card menu and choose Not interested.", QbzSession.trRev)
}
// 3) No results — identical on all three tabs, and no body line (the
// control hides `body` when it is "").
QbzEmptyState {
visible: root.bodyBranch === 3
anchors.centerIn: parent
iconName: "search"
iconSize: 48
iconOpacity: 0.3
titleMuted: true
titleWeight: theme.weightRegular
title: QbzSession.tr("No results for \"{}\"", QbzSession.trRev)
.replace("{}", root.searchQuery)
}
// 4) The list. THREE-edge anchors + an explicit width, never
// `anchors.fill` + `width` — the two are mutually exclusive, the anchor
// wins, the Slint's 18px scrollbar reserve silently never applies AND
// Qt logs a `.qml:` warning the gate greps for.
ListView {
id: rowList
visible: root.bodyBranch === 4
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width - 18
model: root.listRows
spacing: 4
clip: true
// ~2 rows of pre-instantiation past the viewport.
cacheBuffer: 128
boundsBehavior: Flickable.StopAtBounds
onContentYChanged: coverWindow.restart()
onModelChanged: coverWindow.restart()
// The window is computed from contentY + height, and `height` is 0
// until the Loader has sized the view — without this the first
// dispatch after a mount asks for 3 rows instead of a screenful.
onHeightChanged: coverWindow.restart()
Component.onCompleted: coverWindow.restart()
delegate: BlacklistRow {
required property var modelData
width: rowList.width
arm: root.activeTab === 0 ? "artist"
: root.activeTab === 1 ? "album" : "dismissed"
row: modelData
coverSource: root.activeTab === 1
? (root.coverMap[modelData.coverUrl] || modelData.artPath || "")
: ""
onSelectRequested: {
if (root.activeTab === 1)
QbzBlacklist.openAlbum(modelData.id)
else
QbzBlacklist.openArtist(modelData.id)
}
onRemoveRequested: {
if (root.activeTab === 0)
QbzBlacklist.removeArtist(modelData.id)
else if (root.activeTab === 1)
QbzBlacklist.removeAlbum(modelData.id)
else
QbzBlacklist.removeDismissed(modelData.id)
}
}
}
// The ListScrollbar replica — 14px gutter, inset 4 from the pane's
// right edge, which is exactly the 18px the list gave back.
QbzScrollBar {
// The control's own rule is `visible: maxScroll > 0` (its auto-hide,
// = the Slint ListScrollbar's `auto-hide: true`). Assigning `visible`
// here REPLACES that binding, so the overflow test has to be carried
// over or a short list gets a full-height thumb on gutter hover
// (Sidebar.qml:383 combines the two the same way).
visible: root.bodyBranch === 4
&& rowList.contentHeight > rowList.height
target: rowList
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// ======================== Clear-All confirm ============================
// Declared LAST: declaration order IS z-order, and it also carries z: 3100
// (ADR-009's floor is 3000; 3100 is the layer the port's other in-pane
// modal scrim uses). Cancel / Esc / a backdrop click all dismiss WITHOUT
// mutating.
QbzConfirmModal {
id: clearConfirm
anchors.fill: parent
title: QbzSession.tr("Clear Blacklist?", QbzSession.trRev)
body: root.activeTab === 0
? QbzSession.tr("This will remove all {} blacklisted artists. This cannot be undone.", QbzSession.trRev)
.replace("{}", root.artistCount)
: QbzSession.tr("This will remove all {} blocked albums. This cannot be undone.", QbzSession.trRev)
.replace("{}", root.albumCount)
confirmLabel: QbzSession.tr("Clear All", QbzSession.trRev)
danger: true
onConfirmed: {
if (root.activeTab === 0)
QbzBlacklist.clearAllArtists()
else
QbzBlacklist.clearAllAlbums()
}
}
}