Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc
636 lines
30 KiB
QML
636 lines
30 KiB
QML
// 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.
|
||
// - The old per-view `UiFocusState.text-input-focused` write is unnecessary:
|
||
// AppShell/KioskShell compute the active TextInput and pass that gate through
|
||
// the app-wide QbzHotkeys dispatcher before any binding is considered.
|
||
// - 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.
|
||
// - Mutation feedback uses the app-wide QbzToast host through `toast_qt`;
|
||
// the settled `blacklistChanged` republish also corrects optimistic glyphs.
|
||
//
|
||
// 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
|
||
|
||
// KIOSK HOST (2026-09-07, K6). Default FALSE = the desktop page, byte for
|
||
// byte: the chrome is a Column at x24/y24 and the body starts 14px under
|
||
// it, exactly the arithmetic this file has always had.
|
||
//
|
||
// The kiosk panel is 784x256 at the Pi floor. The desktop arithmetic
|
||
// leaves the body 0-39px against a 68px row pitch, so the manager can
|
||
// never draw ONE row (K0b finding #6). Two scroll axes are not an option
|
||
// (a ListView nested in a Flickable steals every vertical drag), so the
|
||
// kiosk arm folds the whole chrome into `rowList`'s ListView.header and
|
||
// its empty/loading/no-results states into its footer: ONE flickable, the
|
||
// header scrolls away, and the rows own the full panel. The chrome and
|
||
// the state block are declared ONCE, as Components, and mounted either in
|
||
// the desktop position or inside the list.
|
||
property bool kioskHost: false
|
||
|
||
// 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: theme.ambientOn
|
||
|
||
// 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 on desktop, 76 + 4 = 80 in kiosk
|
||
/// (BlacklistRow.qml's own frame height). 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.
|
||
readonly property int rowPitch: root.kioskHost ? 80 : 68
|
||
function reportCoverWindow() {
|
||
if (root.activeTab !== 1)
|
||
return
|
||
var m = root.listRows
|
||
if (m.length === 0)
|
||
return
|
||
// In kiosk the chrome rides in ListView.header, so contentY starts
|
||
// NEGATIVE of the header height; clamping at 0 keeps the first window
|
||
// anchored on row 0 instead of asking for a negative index.
|
||
var top = Math.max(0, rowList.contentY)
|
||
var first = Math.max(0, Math.floor(top / root.rowPitch) - 2)
|
||
var last = Math.min(m.length - 1,
|
||
Math.ceil((top + rowList.height) / root.rowPitch) + 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.
|
||
|
||
// The desktop chrome host. Same x/y/width the Column had; a Loader with
|
||
// only its width set takes the item's implicitHeight, so `head.height` is
|
||
// still the Column's content height and the body arithmetic below is
|
||
// unchanged. `sourceComponent: null` in kiosk — the chrome is the list's
|
||
// header there and must exist exactly once.
|
||
Loader {
|
||
id: head
|
||
x: 24
|
||
y: 24
|
||
width: root.width - 48
|
||
sourceComponent: root.kioskHost ? null : chromeComponent
|
||
}
|
||
|
||
Component {
|
||
id: chromeComponent
|
||
Column {
|
||
id: chromeCol
|
||
// Desktop: the Loader sizes this to `head.width`. Kiosk: the item is
|
||
// ListView.header, which does NOT stretch its header, so it takes the
|
||
// view width itself.
|
||
width: chromeCol.ListView.view ? chromeCol.ListView.view.width : head.width
|
||
spacing: 14
|
||
// Desktop = 0 on both, so the Column's implicitHeight — and therefore
|
||
// `head.height` — is exactly what it was.
|
||
topPadding: root.kioskHost ? 4 : 0
|
||
bottomPadding: root.kioskHost ? 12 : 0
|
||
|
||
// ---- 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 {
|
||
kioskHost: root.kioskHost
|
||
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: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
|
||
// ---- controls row (:480-646) — spacing 12 ------------------------
|
||
Item {
|
||
width: parent.width
|
||
height: root.kioskHost ? 44 : 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 {
|
||
id: toggleChip
|
||
width: toggleRow.implicitWidth + 28
|
||
height: root.kioskHost ? 44 : 34
|
||
radius: theme.radiusSm
|
||
color: toggleArea.containsMouse ? theme.surfaceElevatedHover
|
||
: 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: root.kioskHost
|
||
? theme.fontLegal * 1.2 : 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
|
||
kioskHost: root.kioskHost
|
||
searchMode: true
|
||
// Desktop keeps the 280 override verbatim. In kiosk the
|
||
// row shares a 760px panel with a taller toggle, the
|
||
// Clear-All chip and the right-anchored count, so the
|
||
// field takes the measured slack instead of a constant —
|
||
// that is what keeps the count badge from overlapping it
|
||
// at the 800x480 floor.
|
||
width: root.kioskHost
|
||
? Math.max(140, chromeCol.width - toggleChip.width
|
||
- (clearChip.visible ? clearChip.width + 12 : 0)
|
||
- countBadge.width - 36)
|
||
: 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 {
|
||
id: clearChip
|
||
visible: root.activeTab === 0 ? root.artistCount > 0
|
||
: root.activeTab === 1 ? root.albumCount > 0 : false
|
||
width: clearRow.implicitWidth + 28
|
||
height: root.kioskHost ? 44 : 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: root.kioskHost
|
||
? theme.fontLegal * 1.2 : 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 {
|
||
id: countBadge
|
||
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: root.kioskHost
|
||
? theme.fontLegal * 1.2 : 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The three non-list body branches, declared ONCE. Desktop mounts this
|
||
// filling `bodyHost` (so every `anchors.centerIn: parent` centres on the
|
||
// body exactly as before); kiosk mounts it as `rowList`'s footer, where it
|
||
// takes the view width and a fixed band so it lands under the chrome
|
||
// instead of being centred behind it.
|
||
Component {
|
||
id: statesComponent
|
||
Item {
|
||
id: statesItem
|
||
width: statesItem.ListView.view
|
||
? statesItem.ListView.view.width : (parent ? parent.width : 0)
|
||
height: statesItem.ListView.view
|
||
? (root.bodyBranch === 4 ? 0 : 200)
|
||
: (parent ? parent.height : 0)
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================== body ==================================
|
||
Item {
|
||
id: bodyHost
|
||
// Kiosk trades the desktop 24px page gutter for 12 — at 784x256 the
|
||
// gutters alone are 19% of the panel.
|
||
x: root.kioskHost ? 12 : 24
|
||
y: root.kioskHost ? 12 : head.y + head.height + 14
|
||
width: root.width - (root.kioskHost ? 24 : 48)
|
||
height: Math.max(0, root.height - y - (root.kioskHost ? 12 : 24))
|
||
|
||
Loader {
|
||
anchors.fill: parent
|
||
sourceComponent: root.kioskHost ? null : statesComponent
|
||
}
|
||
|
||
// 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
|
||
// In kiosk the list is the ONLY scroller — it carries the chrome
|
||
// in its header and the loading/empty/no-results states in its
|
||
// footer, so it stays mounted on every branch. The model is
|
||
// emptied instead of the view being hidden, which also drops the
|
||
// stale rows a `loading` republish would otherwise leave under the
|
||
// spinner.
|
||
visible: root.kioskHost || root.bodyBranch === 4
|
||
anchors.left: parent.left
|
||
anchors.top: parent.top
|
||
anchors.bottom: parent.bottom
|
||
width: parent.width - (root.kioskHost ? 0 : 18)
|
||
model: (root.kioskHost && root.bodyBranch !== 4) ? [] : root.listRows
|
||
header: root.kioskHost ? chromeComponent : null
|
||
footer: root.kioskHost ? statesComponent : null
|
||
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
|
||
kioskHost: root.kioskHost
|
||
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.
|
||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||
// this container's offset while it is the live page, and restores it
|
||
// when a back/forward step arms this route.
|
||
ScrollMemory { target: rowList; scope: "blacklist" }
|
||
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.kioskHost || 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
|
||
kioskHost: root.kioskHost
|
||
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()
|
||
}
|
||
}
|
||
}
|