Files
qbz/crates/qbz-qt/qml/views/local/LocalChrome.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

353 lines
16 KiB
QML

// 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 `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.
//
// Total height 91px — the content area subtracts exactly this.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
QbzTheme { id: theme }
height: 91
// ---- Row 1: title + gear + Plex sync ----
Item {
width: parent.width
height: 46
Row {
id: headerActions
x: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Local Library", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
}
// Folder management, scan and maintenance do NOT live in this
// view (Slint header note) — the gear routes out.
QbzNavButton {
id: gearBtn
name: "settings-2"
anchors.verticalCenter: parent.verticalCenter
// Land on Settings > Local Library (section 4 in
// settings/SettingsView.qml), not on the Audio root.
onClicked: {
QbzBridge.settingsSetSection(4)
QbzShell.navigateTo("settings")
}
HoverHandler {
onHoveredChanged: tips.hover(hovered, gearBtn, "local-gear",
QbzSession.tr("Manage the folders your library is built from",
QbzSession.trRev))
}
}
// ---- Refresh ▾ -------------------------------------------
// Before Open, per the owner's layout: the two maintenance glyphs
// (settings, resync) sit together, and the ACTION that produces
// new content reads last and carries a label.
Item {
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
QbzNavButton {
id: refreshBtn
anchors.fill: parent
name: "refresh-cw"
// 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)
HoverHandler {
onHoveredChanged: tips.hover(hovered, refreshBtn, "refresh-chip",
QbzSession.tr("Re-scan your folders and media servers for new music",
QbzSession.trRev))
}
}
QbzSpinner {
// 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
}
}
// ---- [icon] Open ▾ ---------------------------------------
// A LABELLED chip, not a bare glyph: this is the one door for
// every medium, and a folder icon alone reads as "browse folders"
// next to a tab literally called Folders. 30px / radius 6 is the
// small toolbar-control contract this header's own selects use.
Rectangle {
id: openBtn
anchors.verticalCenter: parent.verticalCenter
width: openRow.width + 20
height: 30
radius: 6
// An open is in flight. Spinning a drive up and reading a TOC
// takes SECONDS, and until this existed those seconds looked
// exactly like a click that missed — so the chip both says so
// and stops taking a second click onto a drive it is already
// reading.
readonly property bool busy: QbzLocal.localDiscOpening
color: (!openBtn.busy && (openArea.containsMouse || openMenu.opened))
? theme.elevatedHoverFill
: theme.elevatedFill
Row {
id: openRow
anchors.centerIn: parent
spacing: 6
// The spinner takes the ICON's slot rather than being
// appended: the chip is 15px wider either way, so the
// label and the chevron do not shuffle sideways the
// instant the disc is picked.
Item {
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
QbzIcon {
anchors.fill: parent
name: "folder-open"
visible: !openBtn.busy
tintName: openArea.containsMouse ? "textPrimary" : "secondary"
}
QbzSpinner {
anchors.centerIn: parent
size: 15
// `visible` also stops the rotation (QbzSpinner
// gates on it), so an idle chip presents nothing.
visible: openBtn.busy
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Open", QbzSession.trRev)
color: openArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
}
QbzIcon {
name: "chevron-down"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: openArea.containsMouse ? "textPrimary" : "secondary"
}
}
MouseArea {
id: openArea
anchors.fill: parent
enabled: !openBtn.busy
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: openMenu.openBelowLeft(openBtn)
// What this door is FOR, in one line. "Open" next to a tab
// called Folders reads as "browse my library", which is
// the opposite of what it does.
onContainsMouseChanged: tips.hover(containsMouse, openBtn, "open-chip",
openBtn.busy
? QbzSession.tr("Reading the disc…", QbzSession.trRev)
: QbzSession.tr("Play a folder, CD or disc image without adding it to your library",
QbzSession.trRev))
}
}
}
// The unused centre of the title row carries scan state without
// changing the 91px chrome contract. It reads counters only; no model
// row or delegate is created, so large-library virtualization remains
// untouched. On a narrow window the toolbar keeps priority and the
// compact indicator yields rather than overlapping controls.
LocalScanProgress {
anchors.left: headerActions.right
anchors.leftMargin: 28
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
visible: active && width >= 260
}
}
// ---- Row 2: tabs (left) + per-tab toolbar (right) ----
Item {
y: 46
width: parent.width
height: 44
QbzTabBar {
x: 32
anchors.verticalCenter: parent.verticalCenter
counts: true
underline: true
activeId: root.view.activeTab
// 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.
// 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 byId = {
"albums": { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 },
"artists": { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
"genres": { "id": "genres", "label": QbzSession.tr("Library Explorer", QbzSession.trRev), "count": root.view.explorerLeadingCount },
"folders": { "id": "folders", "label": QbzSession.tr("Folders", QbzSession.trRev), "count": root.view.counts.folders || 0 },
"tracks": { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 }
}
var t = []
for (var i = 0; i < root.view.localTabOrder.length; i++) {
var id = root.view.localTabOrder[i]
if (byId[id]) t.push(byId[id])
}
if (root.view.ephemeralActive)
t.push({ "id": "ephemeral",
"label": root.view.ephemeralLabel,
"count": root.view.ephemeralTrackCount })
return t
}
onSelected: function (id) { root.view.activateTab(id) }
}
LocalToolbar {
x: parent.width - width - 32
anchors.verticalCenter: parent.verticalCenter
view: root.view
}
}
Rectangle {
y: 90
width: parent.width
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
// W16: the audio-CD row is dropped on Windows -- qbz-disc's CDDA
// backend is `mod unsupported` there and answers NoDrive to
// everything. SACD is an image file, so it stays on every platform.
entries: [
{ "label": QbzSession.tr("Open folder…", QbzSession.trRev),
"icon": "folder-open", "action": "folder" }
].concat(QbzShell.isWindows ? [] : [
{ "label": QbzSession.tr("Open audio CD", QbzSession.trRev),
"icon": "disc", "action": "cd" }
]).concat([
{ "label": QbzSession.tr("Open SACD image…", QbzSession.trRev),
"icon": "disc-3", "action": "sacd" }
])
onPicked: function (action) {
if (action === "folder") QbzLocal.ephemeralOpen()
else if (action === "cd") QbzLocal.ephemeralOpenCd()
else if (action === "sacd") QbzLocal.ephemeralOpenSacd()
}
}
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()
// A button labelled Resync is an authoritative reconciliation,
// not a delta. Jellyfin's DateLastSaved delta cannot report rows
// whose provider ids disappeared (for example after a library
// rebuild), so using it here retained the old ids beside the new
// ones and rendered one physical copy as two versions. Automatic
// background catch-up may still use deltas; an explicit repair
// must observe the complete source before it authorizes pruning.
if ((action === "jellyfin" || action === "all") && QbzLocal.mediaHasJellyfin)
QbzLocal.mediaSync("jellyfin", true)
if ((action === "subsonic" || action === "all") && QbzLocal.mediaHasSubsonic)
QbzLocal.mediaSync("subsonic", true)
}
}
// Hover tooltips for this header's controls. The overlay takes no pointer
// and owns no animator, so an idle one costs nothing.
QbzTooltip {
id: tips
anchors.fill: parent
z: 900
}
}