Materialize source-scoped artist aggregates and FTS, then expose exact keyset pages and bounded A-Z metadata for the existing Artists rail. Query the selected artist's albums through the indexed credit relationship with an independent bounded model, generation cancellation, artwork updates, and per-surface legacy fallback. cx
377 lines
16 KiB
QML
377 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"
|
|
&& ((QbzLocal.localArtistsNativeActive
|
|
? QbzLocal.localArtistsNativeTotal > 0
|
|
: root.view.artists.length > 0)
|
|
|| root.view.artistsSearch !== ""
|
|
|| QbzLocal.localArtistsLoading)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
placeholder: QbzSession.tr("Search artists", QbzSession.trRev)
|
|
property string pending: ""
|
|
onEdited: function (v) {
|
|
pending = v
|
|
artistsSearchDebounce.restart()
|
|
}
|
|
Timer {
|
|
id: artistsSearchDebounce
|
|
interval: 200
|
|
onTriggered: root.view.artistsSearch = parent.pending
|
|
}
|
|
}
|
|
}
|