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
394 lines
16 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|