Files
qbz/crates/qbz-qt/qml/views/local/LocalChrome.qml
T
vicrodh 2273db9184 feat(qt): the disc surfaces — rip wizard, metadata correction, and a pane that
can be read

RIP WIZARD, three steps, each answering something the first version got wrong
by not asking. What will happen — which disc, how many tracks, and that the
output is FLAC, stated rather than implied. The tracks, editable, because a
CD-DA carries no titles at all so "named wrong" is the ordinary case and once
files are on disk it is a rename job. And what happens afterwards: a ripped
album the library cannot see is a folder full of files, so the destination is
compared against the registered folders — by path COMPONENTS, not by string
prefix, since /music-backup starts with /music and is not inside it — and the
right follow-up is offered, naming the folder it would re-scan. "Do nothing" is
an explicit choice rather than the silent default. The follow-up runs only on
success.

Tracks can be deselected. Everything is ticked on arrival and the user goes
from all to fewer; a wizard that starts empty makes the common case the one
that costs the most clicks. On the Rust side an absent `selected` means yes, so
an older form cannot silently rip nothing.

A PROGRESS PANEL behind the ripping chip, which is a door rather than a label:
it is the only thing on screen during a rip and the two questions it could not
answer are the ones that matter. A soft lock says not to eject — soft because
nothing here can hold a tray shut, so the honest move is to say it and keep
saying it. Per-track state with a bar on the row that is actually moving.
Stopping is offered, deletes nothing, and says so; a cancelled job does not
jump to 100 %, which is the one lie a progress bar must not tell.

CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder,
which has no identity to write a correction under. The list shows what
distinguishes four pressings of one album rather than four identical titles,
and flags a track count that disagrees with the disc. Selecting is not
applying: the candidate is fetched in full and its track list shown, because
that list is the only thing proving a plausible title is the right pressing.
Applying renames the live session without renumbering ids — a re-open would
orphan a playing track — and writes a user row. Only MusicBrainz re-resolves
the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is
worse than the old one. The correction can be dropped, because without an
escape a bad apply is permanent and that makes people afraid of the button.

THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track
set whose album_group_key is the box root for every file while the disc number
sits in the data unused — so the pane was a flat list 247 rows long. The key
carries the disc, unconditionally, because it round-trips through the play-album
lookup. Its own client-side filter over what is already rendered, since finding
one track meant scrolling past the other 246.

EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published
isEphemeral on every row for a while and the queue panel respected it; the bar
never read it. The heart goes inert, the add menu keeps only its queue actions,
and the row menu comes back with those three and nothing else. Absent rather
than rendered-and-inert.

Also: a spinner on Open that blocks a second click onto a drive already being
read, held by a counting RAII guard because opening is a relay and the chip
must not blink off in the seam; the now-playing card routes a disc to the pane
it came from instead of to an album id no catalogue can open; the file pickers
use rfd, the platform's own dialog, which the POC-NOTE said was a five-line
change once the dependency existed — it existed, Settings had been calling it
from this crate for weeks, and on macOS the four Linux choosers do not exist so
Open folder and Open SACD image opened nothing at all, in silence; the empty
state stands down for the Open pane, which on a machine with no library drew
both fully painted; tooltips on every disc control through one hover() helper,
since two call sites had already mismatched their key, which does not fail —
the bubble simply never goes away.

cc
2026-08-21 18:58:22 -06:00

315 lines
14 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 {
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
onClicked: 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.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
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))
}
}
}
}
// ---- 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 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 }
}
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
entries: [
{ "label": QbzSession.tr("Open folder…", QbzSession.trRev),
"icon": "folder-open", "action": "folder" },
{ "label": QbzSession.tr("Open audio CD", QbzSession.trRev),
"icon": "disc", "action": "cd" },
{ "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()
if ((action === "jellyfin" || action === "all") && QbzLocal.mediaHasJellyfin)
QbzLocal.mediaSync("jellyfin", false)
if ((action === "subsonic" || action === "all") && QbzLocal.mediaHasSubsonic)
QbzLocal.mediaSync("subsonic", false)
}
}
// 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
}
}