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