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