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:
26 files changed
+3393
-132
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user