Files
qbz/crates/qbz-qt/qml/views/local/LocalToolbar.qml
T
vicrodh 299dcde5b4 feat(library): page Albums from local catalog
Materialize album browse fields and FTS in the derived catalog, then expose exact counts and stable keyset queries for the current filters, sort, grouping, and enabled sources.

Bind the existing Albums layout to a bounded native Qt model with asynchronous page misses, anchors, selection ranges, generation cancellation, artwork updates, and per-surface fallback. Resolve Plex native edition keys directly and retain the metadata-mode legacy reader.

cx
2026-08-23 10:54:31 -06:00

364 lines
16 KiB
QML

// Row-2 right-hand toolbar — the four per-tab control groups from
// LocalLibraryView.slint:774 (Albums), :924 (Tracks), :1009 (Folders) and
// :1103 (Artists). One file because they are one row: exactly one group is
// visible at a time and they share the search box, the select chrome and
// the 8px rhythm.
//
// Every Slint gate is kept: the Albums/Tracks groups only appear once the
// tab has rows OR a live search; the Folders flat-only block hides in tree
// mode while the Flat/Tree toggle stays; Artists shows the search only when
// there are artists.
//
// PRESENTATION DIVERGENCE (owner request — the row was too crowded):
// * search -> the EXPANDABLE form (Slint already uses ExpandableSearch
// here; the port used to mount an always-open 200px box);
// * sort -> LocalIconSelect "arrow-up-down" (Slint: text QbzSelect)
// * grouping -> LocalIconSelect "layers" (Slint: text QbzSelect)
// * filter -> unchanged icon-only button (matches Slint) + a tooltip.
// Every option list, key mapping and callback is untouched.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Row {
id: root
/// The LocalLibraryView root (state + actions).
property var view: null
QbzTheme { id: theme }
readonly property var sortIds: ["artist-asc", "artist-desc", "title-asc",
"title-desc", "year-desc", "year-asc"]
readonly property var sortLabels: [
QbzSession.tr("Artist A-Z", QbzSession.trRev),
QbzSession.tr("Artist Z-A", QbzSession.trRev),
QbzSession.tr("Title A-Z", QbzSession.trRev),
QbzSession.tr("Title Z-A", QbzSession.trRev),
QbzSession.tr("Year (newest)", QbzSession.trRev),
QbzSession.tr("Year (oldest)", QbzSession.trRev),
]
readonly property var groupIds: ["off", "alpha", "artist"]
readonly property var groupLabels: [
QbzSession.tr("No grouping", QbzSession.trRev),
QbzSession.tr("Group by letter", QbzSession.trRev),
QbzSession.tr("Group by artist", QbzSession.trRev),
]
// Tooltip leads for the icon-only controls.
readonly property string sortTip: QbzSession.tr("Sort", QbzSession.trRev)
readonly property string groupTip: QbzSession.tr("Grouping", QbzSession.trRev)
readonly property var tracksSortIds: ["default", "artist-asc", "artist-desc",
"title-asc", "title-desc", "year-desc", "year-asc", "added-desc"]
readonly property var tracksSortLabels: [
QbzSession.tr("Default", QbzSession.trRev),
QbzSession.tr("Artist A-Z", QbzSession.trRev),
QbzSession.tr("Artist Z-A", QbzSession.trRev),
QbzSession.tr("Title A-Z", QbzSession.trRev),
QbzSession.tr("Title Z-A", QbzSession.trRev),
QbzSession.tr("Year (newest)", QbzSession.trRev),
QbzSession.tr("Year (oldest)", QbzSession.trRev),
QbzSession.tr("Date added", QbzSession.trRev),
]
readonly property var tracksGroupIds: ["off", "album", "artist", "name"]
readonly property var tracksGroupLabels: [
QbzSession.tr("No grouping", QbzSession.trRev),
QbzSession.tr("By album", QbzSession.trRev),
QbzSession.tr("By artist", QbzSession.trRev),
QbzSession.tr("By name", QbzSession.trRev),
]
height: 30
spacing: 8
// ============================ ALBUMS =================================
Row {
visible: root.view && root.view.activeTab === "albums"
&& (root.view.albums.length > 0 || root.view.albumsSearch !== ""
|| QbzLocal.localAlbumsLoading
|| QbzLocal.localAlbumsNativeActive)
spacing: 8
height: parent.height
LocalSearchBox {
id: albumsSearchBox
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
// DEBOUNCED, like the Tracks box below — which had it and this one
// did not, in the same file.
//
// `albumsSearch` is not a query, it is the FILTER INPUT: writing it
// re-filters 1267 albums, rebuilds the collection's entry list, and
// hands the ListView a new model — which tears down every delegate
// and builds them again. Per keystroke.
//
// The owner's report names the shape exactly: "the first two
// keystrokes hurt", i.e. while the filter still matches hundreds.
// By the time it matches ten there is nothing left to rebuild, so
// the tail of the word feels fine. That is a model swap, not a
// slow filter.
//
// The box keeps showing what was typed immediately; only the
// filter waits.
property string pending: ""
onEdited: function (v) {
albumsSearchBox.pending = v
albumsSearchDebounce.restart()
}
Timer {
id: albumsSearchDebounce
interval: 200
onTriggered: root.view.albumsSearch = albumsSearchBox.pending
}
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 190
options: root.sortLabels
currentIndex: Math.max(0, root.sortIds.indexOf(root.view.albumsSort))
onSelected: function (i) { root.view.albumsSort = root.sortIds[i] }
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layers"
label: root.groupTip
menuWidth: 180
options: root.groupLabels
marked: root.view.albumsGroup !== "off"
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.albumsGroup))
onSelected: function (i) { root.view.albumsGroup = root.groupIds[i] }
}
// Album identity ("Albums by") — what one album IS. A QUERY change
// (the grouping happens in SQL), so it reloads the set; persisted,
// shared with the Slint app. Kept as a LABELLED select: it changes
// what the tab shows, not how it is ordered, and no icon says that.
QbzSelect {
menuWidth: 190
height: 30
anchors.verticalCenter: parent.verticalCenter
options: [
QbzSession.tr("Albums by folder", QbzSession.trRev),
QbzSession.tr("Albums by metadata", QbzSession.trRev),
]
currentIndex: QbzLocal.localAlbumMode === "metadata" ? 1 : 0
onSelected: function (i) {
QbzLocal.setAlbumMode(i === 1 ? "metadata" : "folder")
}
}
// Quality/format/source filter popup trigger + active-count badge.
Item {
width: 34
height: 30
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.fill: parent
radius: 6
border.width: 1
border.color: root.view.filterCount > 0 ? theme.accent : theme.borderSubtle
color: filtArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
name: "list-filter"
width: 15
height: 15
anchors.centerIn: parent
tintName: root.view.filterCount > 0 ? "accent" : "secondary"
}
MouseArea {
id: filtArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
localFilterTip.exit()
root.view.filterOpen = !root.view.filterOpen
}
onEntered: localFilterTip.enter()
onExited: localFilterTip.exit()
}
}
// WHAT is filtered, not just that something is. The funnel is a
// 10-key one and its badge only ever said a number, so a user
// coming back to a session had to open the popup to find out what
// they had set.
QbzFilterTip {
id: localFilterTip
ownerKey: "local-albums-filter"
anchor: filtArea
groups: root.view.filterSummaryGroups
}
// The plain one-line tip stays for the UNFILTERED case: with
// nothing applied there is no summary to show, and "Quality, format
// and source filters" is what the control needs to say then.
LocalTip {
visible: filtArea.containsMouse && !localFilterTip.hasSummary
text: QbzSession.tr("Quality, format and source filters", QbzSession.trRev)
}
Rectangle {
visible: root.view.filterCount > 0
x: parent.width - width + 3
y: -4
width: 15
height: 15
radius: 7.5
color: theme.accent
Text {
anchors.centerIn: parent
text: root.view.filterCount
// 9px bold on a 15px accent dot — the smallest on-accent
// text in the app, so the floor matters most here. NOT a
// departure from locallibrary/LocalLibraryView.slint:887
// (`Theme.accent-text`); the twin returns accent-text on
// 34 of the 35 palettes and only overrides rose-pine-dawn
// (2.56:1 -> 7.38:1).
color: theme.accentGlyphColor
font.pixelSize: 9
font.weight: theme.weightBold
}
}
}
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "grid", "icon": "layout-grid" },
{ "id": "list", "icon": "list" }]
mode: root.view.albumsView
onSetMode: function (v) { root.view.albumsView = v }
}
QbzNavButton {
anchors.verticalCenter: parent.verticalCenter
name: "square-check-big"
onClicked: root.view.toggleAlbumsMultiSelect()
}
}
// ============================ TRACKS =================================
Row {
visible: root.view && root.view.activeTab === "tracks"
&& ((QbzLocal.localTracksNativeActive
? QbzLocal.localTracksNativeTotal > 0
: root.view.tracks.length > 0)
|| root.view.tracksSearch !== "")
spacing: 8
height: parent.height
LocalSearchBox {
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) {
root.view.tracksSearch = v
tracksSearchDebounce.restart()
}
// Server-side search: debounce so a keystroke is not a query.
Timer {
id: tracksSearchDebounce
interval: 250
onTriggered: QbzLocal.tracksSearch(root.view.tracksSearch)
}
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 190
options: root.tracksSortLabels
currentIndex: Math.max(0, root.tracksSortIds.indexOf(QbzLocal.localTracksSort))
onSelected: function (i) { QbzLocal.tracksSetSort(root.tracksSortIds[i]) }
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layers"
label: root.groupTip
menuWidth: 180
options: root.tracksGroupLabels
marked: root.view.tracksGroup !== "off"
currentIndex: Math.max(0, root.tracksGroupIds.indexOf(root.view.tracksGroup))
// Through the bridge, like the sort above and the album identity
// below it: the choice is PERSISTED (locallibrary_ui.json, shared
// with the Slint build). The legacy reader reorders its loaded
// rows; Phase E resets an immutable SQL descriptor so grouping is
// global across every keyset page.
onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) }
}
// Multi-select LAST — never to the LEFT of a search box. The expandable
// field keeps a 30px footprint and grows its inner box LEFT
// (QbzLineEdit.qml:157-161, `x: root.width - width`), so anything
// positioned before it is covered while the search is open and cannot
// be clicked. This button used to lead the row and was exactly that.
// Same slot the Albums group puts it in, so the two tabs also agree.
QbzNavButton {
anchors.verticalCenter: parent.verticalCenter
name: "square-check-big"
onClicked: root.view.toggleTracksMultiSelect()
}
}
// ============================ FOLDERS ================================
Row {
visible: root.view && root.view.activeTab === "folders"
spacing: 8
height: parent.height
// Flat-only controls — the tree rail carries its own search.
Row {
visible: root.view.foldersMode === "flat"
&& (root.view.folders.length > 0 || root.view.foldersSearch !== "")
spacing: 8
height: parent.height
LocalSearchBox {
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) { root.view.foldersSearch = v }
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 170
options: root.sortLabels
currentIndex: Math.max(0, root.sortIds.indexOf(root.view.foldersSort))
onSelected: function (i) { root.view.foldersSort = root.sortIds[i] }
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layers"
label: root.groupTip
menuWidth: 180
options: root.groupLabels
marked: root.view.foldersGroup !== "off"
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.foldersGroup))
onSelected: function (i) { root.view.foldersGroup = root.groupIds[i] }
}
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "grid", "icon": "layout-grid" },
{ "id": "list", "icon": "list" }]
mode: root.view.foldersGridView
onSetMode: function (v) { root.view.foldersGridView = v }
}
}
// Flat / Tree — always visible on the Folders tab.
// ASSET GAP: Slint uses disc-album / folder-tree; neither glyph is
// baked in the Qt icon set yet (see GLUE), so this uses the closest
// shipped pair.
QbzSegToggle {
anchors.verticalCenter: parent.verticalCenter
segments: [{ "id": "flat", "icon": "disc" },
{ "id": "tree", "icon": "folder-open" }]
mode: root.view.foldersMode
onSetMode: function (v) { root.view.foldersMode = v }
}
}
// ============================ ARTISTS ================================
LocalSearchBox {
visible: root.view && root.view.activeTab === "artists"
&& root.view.artists.length > 0
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search artists", QbzSession.trRev)
onEdited: function (v) { root.view.artistsSearch = v }
}
}