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
This commit is contained in:
vicrodh committed 2026-08-20 21:14:36 -06:00
1 parent a0ed0d752f
commit cb4c03ca46
7 files changed
+177 -39

No files matched your search

+8 -1
View File
@@ -161,12 +161,19 @@ Item {
"label": nav.trs("Local Library"),
"qobuz": false,
"enabled": true,
// The `Open` entry is APPENDED, not marked enabled:false —
// a disabled row still renders (NavFlyout.qml:329,364), and a
// permanently greyed "Open" would advertise a tab that mostly
// does not exist. `localEphemeralActive` is the same flag the
// tab bar and the pane read.
"entries": [
{ "label": nav.trs("Albums"), "icon": "disc", "view": "local", "tab": "albums", "enabled": true },
{ "label": nav.trs("Artists"), "icon": "user", "view": "local", "tab": "artists", "enabled": true },
{ "label": nav.trs("Folders"), "icon": "folder", "view": "local", "tab": "folders", "enabled": true },
{ "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true }
]
].concat(QbzLocal.localEphemeralActive
? [{ "label": QbzLocal.localEphemeralLabel, "icon": "folder-open", "view": "local", "tab": "ephemeral", "enabled": true }]
: [])
},
{
"id": "myqbz",
@@ -186,6 +186,21 @@ Rectangle {
readonly property bool ephemeralActive: QbzLocal.localEphemeralActive
/// Badge count for the ephemeral tab. Reads the SAME document the pane
/// renders, so the number on the tab and the rows behind it are one fact.
readonly property int ephemeralTrackCount:
ephemeral && ephemeral.trackCount ? ephemeral.trackCount : 0
/// The ephemeral tab's LABEL — the name of the thing you opened, with no
/// verb in front of it. Computed in Rust (`local_ephemeral::display_label`)
/// and read here, so this view and `NavFlyout` cannot name it differently:
/// the flyout does not parse the ephemeral document and should not start.
///
/// A verb was tried and rejected: "Now Playing" / "Playing: …" is false the
/// moment a folder sits open while something else plays, and the tab would
/// contradict the now-playing bar on the same screen.
readonly property string ephemeralLabel: QbzLocal.localEphemeralLabel
// ---------------------------- documents ------------------------------
function parseDoc(json, fallback) {
if (json === "") return fallback
@@ -975,6 +990,19 @@ Rectangle {
active: QbzLocal.localAvailable && root.activeTab === "tracks"
sourceComponent: LocalTracksTab { view: root }
}
// The `Open` tab, previously the ephemeral ARM of the Folders tab.
//
// NOT gated on `localAvailable`, unlike its four siblings: an
// ephemeral session is content from OUTSIDE the indexed library,
// so a user with an empty (or unscanned) library must still be
// able to open a folder or a disc and play it. Gating it would
// hide the pane behind the "no local library" empty state for
// exactly the people the feature exists for.
Loader {
anchors.fill: parent
active: root.activeTab === "ephemeral" && root.ephemeralActive
sourceComponent: LocalEphemeralPane { view: root }
}
}
}
+118 -13
View File
@@ -1,8 +1,19 @@
// Local Library FIXED CHROME (LocalLibraryView.slint:710-1125) — the
// mandatory two-row header this app's list views all share:
//
// row 1 (46px) "Local Library" + the settings gear + the Plex re-sync
// button (#573, only when Plex is enabled + authenticated)
// row 1 (46px) "Local Library" + the settings gear + the `Open` menu +
// the `Refresh` menu
//
// `Open` is ALWAYS present: it is the one door for every
// medium that can become an ephemeral session (a folder
// today; a CD and a SACD image next). It replaces the
// folder-open icon that used to sit in the Folders tree rail,
// where it was reachable only from one arm of one tab.
//
// `Refresh` replaces the Plex-only re-sync icon (#573). Every
// row is gated on a source actually being configured, so a
// user with no media servers still sees exactly one usable
// entry (local folders) plus "all".
// row 2 (44px) the segmented tab bar with count badges on the left, the
// per-tab toolbar floated right
// then the 1px divider.
@@ -45,19 +56,32 @@ Item {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzShell.navigateTo("settings")
}
// ---- Open ▾ ----------------------------------------------
QbzNavButton {
id: openBtn
name: "folder-open"
anchors.verticalCenter: parent.verticalCenter
onClicked: openMenu.openBelowLeft(openBtn)
}
// ---- Refresh ▾ -------------------------------------------
Item {
visible: QbzLocal.plexAvailable
width: visible ? 28 : 0
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
QbzNavButton {
id: refreshBtn
anchors.fill: parent
name: "refresh-cw"
btnEnabled: !QbzLocal.plexSyncing
onClicked: QbzLocal.syncPlex()
// The button stays reachable while a sync runs — the
// individual rows are what go muted, so the user can still
// start a DIFFERENT source's sync (they are independent
// jobs on independent stores).
onClicked: refreshMenu.openBelowLeft(refreshBtn)
}
QbzSpinner {
visible: QbzLocal.plexSyncing
// ANY sync in flight, not just Plex's: the media-server
// sweep has its own flag and its own progress string.
visible: QbzLocal.plexSyncing || QbzLocal.mediaSyncing
anchors.centerIn: parent
size: 15
}
@@ -80,12 +104,24 @@ Item {
// NOTE the `root.view.` prefix: QbzTabBar has its OWN `counts`
// property (the badge arm), so an unqualified `counts` here
// would resolve to that bool, not to the view's document.
tabs: [
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
{ "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 },
]
// The fifth tab EXISTS only while an ephemeral session does. It is
// appended rather than rendered disabled, because a tab for a
// medium that is not inserted is worse than no tab: it invites a
// click that can only fail. `ephemeralActive` is the same flag the
// pane itself keys on, so the tab and its body cannot disagree.
tabs: {
var t = [
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
{ "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 },
]
if (root.view.ephemeralActive)
t.push({ "id": "ephemeral",
"label": root.view.ephemeralLabel,
"count": root.view.ephemeralTrackCount })
return t
}
onSelected: function (id) { root.view.activeTab = id }
}
@@ -102,4 +138,73 @@ Item {
height: 1
color: theme.borderSubtle
}
// ---------------------------- the two menus ---------------------------
// Both are CardMenu (the shared ⋯ surface): an `entries` model in, a
// `picked(action)` out. No new component, and the rows inherit the app's
// one menu chrome.
CardMenu {
id: openMenu
menuWidth: 220
entries: [
{ "label": QbzSession.tr("Open folder…", QbzSession.trRev),
"icon": "folder-open", "action": "folder" }
]
onPicked: function (action) {
if (action === "folder") QbzLocal.ephemeralOpen()
}
}
CardMenu {
id: refreshMenu
menuWidth: 240
// Built as a list so the optional rows can be appended rather than
// rendered muted: a user with no Plex must not see a Plex row at all.
//
// `cloud-download`, not `server`: QbzIcon draws PRE-BAKED svgs from
// `qml/assets/icons/<tint>/`, and a name with no bake renders NOTHING
// — no warning, no error, no failing test. `server` has no bake, and
// the three rows below shipped iconless in the first smoke because of
// it. Check the tint directory before inventing a glyph name.
// `mediaHasJellyfin` / `mediaHasSubsonic` are the SAME gates the media
// server settings panel publishes, so this menu cannot drift from what
// is actually configured.
entries: {
var out = [
{ "label": QbzSession.tr("Resync all", QbzSession.trRev),
"icon": "refresh-cw", "action": "all",
"enabled": !QbzLocal.mediaSyncing && !QbzLocal.plexSyncing },
{ "sep": true },
{ "label": QbzSession.tr("Resync local folders", QbzSession.trRev),
"icon": "hard-drive", "action": "local" }
]
if (QbzLocal.plexAvailable)
out.push({ "label": QbzSession.tr("Resync Plex", QbzSession.trRev),
"icon": "cloud-download", "action": "plex",
"enabled": !QbzLocal.plexSyncing })
if (QbzLocal.mediaHasJellyfin)
out.push({ "label": QbzSession.tr("Resync Jellyfin", QbzSession.trRev),
"icon": "cloud-download", "action": "jellyfin",
"enabled": !QbzLocal.mediaSyncing })
if (QbzLocal.mediaHasSubsonic)
out.push({ "label": QbzSession.tr("Resync Navidrome", QbzSession.trRev),
"icon": "cloud-download", "action": "subsonic",
"enabled": !QbzLocal.mediaSyncing })
return out
}
onPicked: function (action) {
// "library-scan" with an empty payload = every ENABLED folder,
// which is what the settings panel's own Scan button sends
// (LibraryFolderTable.qml:75).
if (action === "local" || action === "all")
QbzBridge.settingsString("library-scan", "")
if ((action === "plex" || action === "all") && QbzLocal.plexAvailable)
QbzLocal.syncPlex()
if ((action === "jellyfin" || action === "all") && QbzLocal.mediaHasJellyfin)
QbzLocal.mediaSync("jellyfin", false)
if ((action === "subsonic" || action === "all") && QbzLocal.mediaHasSubsonic)
QbzLocal.mediaSync("subsonic", false)
}
}
}
@@ -1,7 +1,11 @@
// Folders tab body (LocalLibraryView.slint:1531) — three mutually exclusive
// arms: the ephemeral pane (an ad-hoc folder is open), FLAT mode (the album
// collection grouped by directory) and TREE mode (the two-pane filesystem
// browser).
// Folders tab body (LocalLibraryView.slint:1531) — two mutually exclusive
// arms: FLAT mode (the album collection grouped by directory) and TREE mode
// (the two-pane filesystem browser).
//
// The ephemeral pane USED to be a third arm here, gated on
// `view.ephemeralActive`. It is now a tab of its own (`"ephemeral"`), so an
// open folder or disc no longer hides the folder browser you were using —
// and a medium that comes and goes cannot take a permanent tab hostage.
//
// The tree divider is also the drag handle that resizes the rail: min 136px
// (half the 272px default), max half the content area — the Slint's own
@@ -19,19 +23,12 @@ Item {
QbzTheme { id: theme }
// ---- Ephemeral ----
LocalEphemeralPane {
anchors.fill: parent
visible: root.view.ephemeralActive
view: root.view
}
// Loading = the shape of the arm that is coming. FLAT mode is the same
// collection the Albums tab renders; TREE mode is the rail + detail pair
// (the rail rows are 34px folder rows with a leading glyph, so the
// placeholder uses the small square art cell).
QbzSkeleton {
visible: QbzLocal.localFoldersLoading && !root.view.ephemeralActive
visible: QbzLocal.localFoldersLoading
&& root.view.foldersMode === "flat"
variant: root.view.foldersGridView === "grid" ? "cardGrid" : "rowList"
anchors.fill: parent
@@ -46,7 +43,7 @@ Item {
phase: root.view.skelPhase
}
Row {
visible: QbzLocal.localFoldersLoading && !root.view.ephemeralActive
visible: QbzLocal.localFoldersLoading
&& root.view.foldersMode !== "flat"
anchors.fill: parent
anchors.topMargin: 12
@@ -73,7 +70,7 @@ Item {
}
}
LocalNote {
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
visible: !QbzLocal.localFoldersLoading
&& root.view.folders.length === 0 && root.view.foldersSearch === ""
text: QbzSession.tr("No folders in your local library yet.", QbzSession.trRev)
}
@@ -81,7 +78,7 @@ Item {
// ---------------------------- FLAT MODE ------------------------------
Item {
anchors.fill: parent
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
visible: !QbzLocal.localFoldersLoading
&& root.view.folders.length > 0 && root.view.foldersMode === "flat"
LocalNote {
@@ -114,7 +111,7 @@ Item {
// ---------------------------- TREE MODE ------------------------------
Item {
anchors.fill: parent
visible: !QbzLocal.localFoldersLoading && !root.view.ephemeralActive
visible: !QbzLocal.localFoldersLoading
&& root.view.folders.length > 0 && root.view.foldersMode === "tree"
LocalTreeRail {
@@ -293,7 +293,6 @@ Row {
// ============================ FOLDERS ================================
Row {
visible: root.view && root.view.activeTab === "folders"
&& !root.view.ephemeralActive
spacing: 8
height: parent.height
@@ -47,20 +47,16 @@ Item {
iconSize: 15
onClicked: QbzLocal.treeCollapseAll()
}
QbzIconButton {
name: "folder-open"
btnSize: 30
iconSize: 15
onClicked: QbzLocal.ephemeralOpen()
}
// Filler: the search collapses to a 30px magnifier, so the slot
// stays right-aligned and the field opens LEFT over this gap.
Item {
width: Math.max(0, parent.width - 3 * 30 - 30 - 4 * 4)
// TWO buttons now, not three: "open folder" moved to the
// header's `Open` menu, which serves every medium.
width: Math.max(0, parent.width - 2 * 30 - 30 - 3 * 4)
height: 1
}
LocalSearchBox {
boxWidth: parent.width - 3 * 34
boxWidth: parent.width - 2 * 34
placeholder: QbzSession.tr("Search folders", QbzSession.trRev)
onEdited: function (v) {
root.view.treeSearch = v
+6
View File
@@ -209,6 +209,12 @@ pub(crate) fn load_tab_impl(tab: String) {
"artists" => load_artists(),
"folders" => load_folders(),
"tracks" => load_tracks(true),
// The ephemeral session lives in memory and is published the moment it
// is opened — there is nothing to fetch when its tab is selected. It
// still needs an ARM: without one it lands in the `_` below and every
// switch to the tab logs "unknown tab", which is how a real routing
// bug would look.
"ephemeral" => {}
_ => log::warn!("[qbz-qt] local: unknown tab {tab}"),
}
}