Files
qbz/crates/qbz-qt/qml/settings/LibraryFolderTable.qml
T
vicrodh b1dba198c9 feat(qt): kiosk profile release hardening
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
2026-09-07 08:23:11 -06:00

394 lines
16 KiB
QML

// Settings > Local Library > LIBRARY FOLDERS — the folder-management block of
// crates/qbz-ui/ui/settings/LocalLibrarySettings.slint (lines 256-421): the
// toolbar, the filter, the compact folder table (checkbox · folder · last
// scan · status · actions) and the determinate scan progress with its Stop.
//
// Split out of LocalLibrarySettings.qml so that panel stays a thin section
// composition. It owns its own pure-UI state (filter + selection), exactly
// like the Slint keeps it in LibraryFoldersState; everything else rides the
// settings document and the settingsString action keys
// (settings_qt/library.rs).
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
property bool kioskHost: false
id: root
/// The `library` sub-document of the settings snapshot.
property var lib: ({})
readonly property var folders: lib.folders || []
// Pure-UI state (the Slint keeps the same in LibraryFoldersState).
property string filter: ""
property string pendingPath: ""
property var selectedIds: []
QbzTheme { id: theme }
spacing: 4
function shown() {
const q = filter.trim().toLowerCase()
if (q === "") return folders
return folders.filter(function (f) {
return (f.displayName || "").toLowerCase().indexOf(q) >= 0
|| (f.path || "").toLowerCase().indexOf(q) >= 0
})
}
function isSelected(id) { return selectedIds.indexOf(id) >= 0 }
function toggleSelected(id) {
const next = selectedIds.slice()
const at = next.indexOf(id)
if (at >= 0) next.splice(at, 1); else next.push(id)
selectedIds = next
}
function scanLabel(ts) {
if (!ts) return QbzSession.tr("Never", QbzSession.trRev)
return Qt.formatDateTime(new Date(ts * 1000), "yyyy-MM-dd hh:mm")
}
// Shared column geometry (header + rows MUST agree):
// checkbox 20 · folder (rest) · last-scan 120 · status 84 · actions 110.
readonly property int actionsW: root.kioskHost ? 200 : 110
function folderColW(rowWidth) {
return Math.max(60, rowWidth - (root.kioskHost ? 44 : 20) - 120 - 84 - actionsW - 4 * 12)
}
Item {
width: parent.width
height: root.kioskHost ? 44 : 34
GroupHeader { kioskHost: root.kioskHost;
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("LIBRARY FOLDERS", QbzSession.trRev)
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Scan every enabled folder.
SettingsButton { kioskHost: root.kioskHost;
iconName: "refresh-cw"
enabled: root.lib.scanning !== true
onClicked: QbzBridge.settingsString("library-scan", "")
}
// Remove the selected folders (their indexed tracks go with them).
SettingsButton { kioskHost: root.kioskHost;
iconName: "trash-2"
danger: true
enabled: root.selectedIds.length > 0
onClicked: {
QbzBridge.settingsString("library-remove-folders",
JSON.stringify(root.selectedIds))
root.selectedIds = []
}
}
}
}
SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Add folder", QbzSession.trRev)
description: QbzSession.tr("Browse for a music folder, or type the full path.", QbzSession.trRev)
Row {
spacing: 8
QbzLineEdit { kioskHost: root.kioskHost;
id: pathField
width: 190
anchors.verticalCenter: parent.verticalCenter
placeholder: "/home/you/Music"
onEdited: function (v) { root.pendingPath = v }
onCommitted: function (v) { root.pendingPath = v }
}
// The native chooser (settings_qt/library.rs pick_and_add_folder).
// It adds the folder itself, so there is nothing to clear here —
// the typed field is the OTHER route to the same insert, kept
// because pasting a path is faster for network mounts.
SettingsButton { kioskHost: root.kioskHost;
iconName: "folder"
text: QbzSession.tr("Browse...", QbzSession.trRev)
onClicked: QbzBridge.settingsString("library-pick-folder", "")
}
SettingsButton { kioskHost: root.kioskHost;
iconName: "folder-plus"
text: QbzSession.tr("Add", QbzSession.trRev)
enabled: root.pendingPath !== ""
onClicked: {
QbzBridge.settingsString("library-add-folder", root.pendingPath)
root.pendingPath = ""
pathField.text = ""
}
}
}
}
Text {
visible: (root.lib.status || "") !== ""
width: parent.width
text: root.lib.status || ""
color: theme.danger
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
wrapMode: Text.WordWrap
}
// Filter.
Item {
width: parent.width
height: root.kioskHost ? 44 : 38
QbzLineEdit { kioskHost: root.kioskHost;
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 220
searchMode: true
sm: true
placeholder: QbzSession.tr("Filter folders...", QbzSession.trRev)
onEdited: function (q) { root.filter = q }
}
}
// Empty state.
Text {
visible: root.folders.length === 0
width: parent.width
text: root.filter === ""
? QbzSession.tr("No folders yet. Add a folder to build your local library.", QbzSession.trRev)
: QbzSession.tr("No folders match your filter.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
wrapMode: Text.WordWrap
}
// Table header (columns MUST match the rows below).
Item {
visible: root.folders.length > 0
width: parent.width
height: 26
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 12
spacing: 12
Item { width: root.kioskHost ? 44 : 20; height: 1 }
Text {
width: root.folderColW(parent.width)
height: parent.height
text: QbzSession.tr("FOLDER", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Text {
width: 120
height: parent.height
text: QbzSession.tr("LAST SCAN", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Text {
width: 84
height: parent.height
text: QbzSession.tr("STATUS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Item { width: root.actionsW; height: 1 }
}
}
Rectangle {
visible: root.folders.length > 0
width: parent.width
height: 1
color: theme.borderSubtle
}
Repeater {
model: root.shown()
delegate: Rectangle {
id: folderRow
required property var modelData
width: root.width
height: root.kioskHost ? 64 : 40
radius: theme.radiusSm
color: root.isSelected(modelData.id) ? theme.surfaceElevated
: rowArea.containsMouse ? theme.surfaceHover : "transparent"
// Declared FIRST so the per-row buttons (declared after) take
// their own clicks; the empty row area falls through to select.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleSelected(folderRow.modelData.id)
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 12
spacing: 12
QbzCheckbox { kioskHost: root.kioskHost;
width: root.kioskHost ? 44 : 20
anchors.verticalCenter: parent.verticalCenter
checked: root.isSelected(folderRow.modelData.id)
onToggled: root.toggleSelected(folderRow.modelData.id)
}
// FOLDER: type glyph + name.
Row {
width: root.folderColW(parent.width)
height: parent.height
spacing: 8
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
// POC-NOTE: no `network` glyph in this icon set — a
// network folder uses `link`, a local one hard-drive.
name: folderRow.modelData.isNetwork ? "link" : "hard-drive"
width: 16
height: 16
tintName: folderRow.modelData.isNetwork
? (folderRow.modelData.accessible ? "accent" : "favorite")
: "secondary"
}
Text {
width: parent.width - 24
height: parent.height
text: folderRow.modelData.displayName || folderRow.modelData.path
color: folderRow.modelData.enabled ? theme.textPrimary : theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Text {
width: 120
height: parent.height
text: root.scanLabel(folderRow.modelData.lastScan)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// STATUS (read-only, like the Slint).
Text {
width: 84
height: parent.height
text: !folderRow.modelData.enabled
? QbzSession.tr("Disabled", QbzSession.trRev)
: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible)
? QbzSession.tr("Unavailable", QbzSession.trRev)
: QbzSession.tr("Active", QbzSession.trRev)
color: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible
&& folderRow.modelData.enabled) ? theme.danger : theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// ACTIONS: settings · enable/disable · scan this folder ·
// remove it. The eye button duplicates the edit modal's
// "Enabled" toggle on purpose — it predates the modal and is
// one click instead of three for the thing people do most.
Row {
width: root.actionsW
height: parent.height
spacing: 4
layoutDirection: Qt.RightToLeft
SettingsButton { kioskHost: root.kioskHost;
anchors.verticalCenter: parent.verticalCenter
iconName: "trash-2"
onClicked: QbzBridge.settingsString("library-remove-folders",
JSON.stringify([folderRow.modelData.id]))
}
SettingsButton { kioskHost: root.kioskHost;
anchors.verticalCenter: parent.verticalCenter
iconName: "refresh-cw"
enabled: folderRow.modelData.enabled && root.lib.scanning !== true
onClicked: QbzBridge.settingsString("library-scan",
String(folderRow.modelData.id))
}
SettingsButton { kioskHost: root.kioskHost;
anchors.verticalCenter: parent.verticalCenter
iconName: folderRow.modelData.enabled ? "eye" : "eye-off"
onClicked: QbzBridge.settingsString("library-folder-enabled",
String(folderRow.modelData.id))
}
// The per-folder settings modal (alias, network override
// + fs type, change path). The reference reaches it from
// the same place; this port had no affordance at all
// until the modal landed.
SettingsButton { kioskHost: root.kioskHost;
anchors.verticalCenter: parent.verticalCenter
iconName: "settings-2"
onClicked: QbzBridge.settingsString("library-folder-edit-open",
String(folderRow.modelData.id))
}
}
}
}
}
// --------------------------- scan progress ---------------------------
Item {
visible: root.lib.scanning === true
width: parent.width
height: visible ? (root.kioskHost ? 90 : 76) : 0
Column {
anchors.fill: parent
anchors.topMargin: 14
spacing: 8
Item {
width: parent.width
height: root.kioskHost ? 44 : 30
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Scanning", QbzSession.trRev) + ": "
+ (root.lib.processed || 0) + " / " + (root.lib.total || 0)
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
font.weight: theme.weightMedium
}
SettingsButton { kioskHost: root.kioskHost;
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
danger: true
iconName: "x"
text: QbzSession.tr("Stop", QbzSession.trRev)
onClicked: QbzBridge.settingsString("library-scan-stop", "")
}
}
Rectangle {
width: parent.width
height: 6
radius: 3
color: theme.surfaceElevated
clip: true
Rectangle {
width: parent.width * ((root.lib.total || 0) > 0
? Math.min(1, (root.lib.processed || 0) / root.lib.total) : 0)
height: parent.height
radius: 3
color: theme.accent
}
}
Text {
visible: (root.lib.file || "") !== ""
width: parent.width
text: root.lib.file || ""
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
elide: Text.ElideRight
}
}
}
}