Files
qbz/crates/qbz-qt/qml/views/local/LocalToolbar.qml
T
vicrodh cb4c03ca46 feat(qt): one Open menu and one Refresh menu in the Local Library header
The ephemeral pane used to be a third arm of the Folders tab, reachable only
through a folder-open icon buried in the tree rail — one arm of one mode of
one tab. It also HID the folder browser while it was open. Neither survives
the media this pane is about to hold (a CD, a SACD image).

Row 1 of the header now carries two menus, both `CardMenu` (the shared menu
surface, no new component):

  - `Open`, always present, is the one door for every medium. Only "Open
    folder…" is wired here; the disc entries land with their readers.
  - `Refresh` replaces the Plex-only re-sync icon (#573). Rows are APPENDED
    per configured source rather than rendered disabled, so a user with no
    media servers sees local folders and nothing else. It needed no new Rust:
    `plexAvailable`, `mediaHasJellyfin`, `mediaHasSubsonic`, `syncPlex()`,
    `mediaSync()` and the settings' own "library-scan" all already existed.

The ephemeral session becomes a tab of its own, present only while a session
is. Two failure modes are handled because the tab can VANISH under the user:
closing the session falls back to Albums instead of leaving `activeTab` on a
tab with no body, and opening one navigates to it — while the pane was an arm
of Folders that was implicit, and with a tab of its own, opening a folder from
Albums would otherwise do nothing visible.

`load_tab_impl` gains an "ephemeral" arm: nothing to load (the session is in
memory), but without the arm every switch to the tab logged "unknown tab",
which is what a real routing bug looks like.

Driven under Xvfb, not inferred: both menus open, the gated rows match the
configured servers, opening lands on the tab, the badges read DSD64, closing
removes the tab and falls back to Albums, and the folder tree is now visible
WITH a session open.

cc
2026-08-20 21:14:36 -06:00

359 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 !== "")
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"
&& (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). Writing the QML property directly is what
// made it reset on every restart (PARITY-DEBT #13). No re-query —
// grouping is a client-side reorder, not a new ORDER BY.
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 }
}
}