Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus 22 shared opt-in hosts, shared navigation history with Back/Forward, Albums as the default library tab, native paged Local Library (artists/tracks), lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and artwork sized to the physical frame, static skeletons, 64px transport bar, Settings touch reflow and a persistent Immersive exit. Desktop keeps its prior behavior: every shared component defaults to the desktop path and only opts into Kiosk behavior through explicit properties (origin-relative ScrollMemory, viewport-bounded artwork requests). Regression coverage: kiosk navigation and artwork Node tests added to the runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n strings for all eight locales. Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and 1280x800 and a Desktop A/B with seven pixel-identical captures. 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.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: 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()
|
||
}
|
||
}
|
||
}
|