feat(qt): 2.1.x UI polish round — header selects, seekbar, shortcuts, per-tab filters

- Homologate the three Library header selects to the ambient Group style; Sort
  gains an arrow-down-up glyph, a "Sort" placeholder and an explicit Default,
  and every select's border clears when it returns to its default value.
- Seekbar fluidity: a drag scrubs locally and commits a single seek on release;
  the time readout is a floating opaque tooltip reparented to the overlay so the
  content-frame shadow no longer bleeds noise over it.
- Route the NowPlayingBar info button and title to the LOCAL track-info modal
  for a local row, so a local track no longer opens an empty Qobuz modal.
- App menu shows each entry's shortcut (muted, smaller, right-aligned, resolved
  per platform — Cmd on macOS, Ctrl elsewhere) and gains a Quit entry; the
  keyboard-shortcuts modal adds Close Window and Quit under Navigation, moves
  Immersive below Interface, and tightens row heights. i18n for Quit/Close
  Window across all eight locales.
- Persist each Library tab's filter toggles to disk, independently per tab.

cc
This commit is contained in:
vicrodh committed 2026-09-10 11:21:50 -06:00
1 parent 35c3d368b9
commit 04f6ef44e4
34 files changed
+653 -125

No files matched your search

@@ -7024,3 +7024,9 @@ msgstr "Nein"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Nicht erneut anzeigen"
msgid "Quit"
msgstr "Beenden"
msgid "Close Window"
msgstr "Fenster schließen"
@@ -2624,3 +2624,9 @@ msgstr "No"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Don't show again"
msgid "Quit"
msgstr "Quit"
msgid "Close Window"
msgstr "Close Window"
@@ -7030,3 +7030,9 @@ msgstr "No"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "No volver a mostrar"
msgid "Quit"
msgstr "Salir"
msgid "Close Window"
msgstr "Cerrar ventana"
@@ -7022,3 +7022,9 @@ msgstr "Non"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Ne plus afficher"
msgid "Quit"
msgstr "Quitter"
msgid "Close Window"
msgstr "Fermer la fenêtre"
@@ -7005,3 +7005,9 @@ msgstr "いいえ"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "今後表示しない"
msgid "Quit"
msgstr "終了"
msgid "Close Window"
msgstr "ウィンドウを閉じる"
@@ -7012,3 +7012,9 @@ msgstr "Nee"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Niet opnieuw tonen"
msgid "Quit"
msgstr "Afsluiten"
msgid "Close Window"
msgstr "Venster sluiten"
@@ -7022,3 +7022,9 @@ msgstr "Não"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Não mostrar novamente"
msgid "Quit"
msgstr "Sair"
msgid "Close Window"
msgstr "Fechar janela"
@@ -7043,3 +7043,9 @@ msgstr "Нет"
#: qbz-qt/qml/Main.qml
msgid "Don't show again"
msgstr "Больше не показывать"
msgid "Quit"
msgstr "Выход"
msgid "Close Window"
msgstr "Закрыть окно"
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="m21 8-4-4-4 4" />
<path d="M17 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="m21 8-4-4-4 4" />
<path d="M17 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="m21 8-4-4-4 4" />
<path d="M17 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="m21 8-4-4-4 4" />
<path d="M17 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="m21 8-4-4-4 4" />
<path d="M17 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="m21 8-4-4-4 4" />
<path d="M17 4v16" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>

After

Width:  |  Height:  |  Size: 252 B

+61 -6
View File
@@ -36,6 +36,24 @@ Rectangle {
// Optional sort selector: reselecting an option still emits selected.
property string sortDirection: ""
property bool sm: false
// Optional leading glyph before the label (e.g. a sort field's
// arrow-down-up). Empty = none, and the positioner ignores the hidden
// slot so every existing select is byte-identical.
property string leadingIcon: ""
// When set, the COLLAPSED control always shows this fixed label instead of
// the current option — a placeholder select whose active choice lives in
// the outline + tooltip, not the field (the Sort control). The open list
// still highlights the current option.
property string placeholderText: ""
// "This select holds a non-default choice": draw an accent outline at rest.
// Only the border changes — the fill stays the ambient-aware token, so the
// control still goes translucent under the dynamic background.
property bool outlineActive: false
// Optional shared-tooltip host (a QbzTooltip) + text, shown on hover. Used
// to name the active sort when it differs from the tab default.
property var tooltipHost: null
property string tooltipText: ""
property string tooltipKey: ""
signal selected(int index)
QbzTheme { id: theme }
@@ -44,8 +62,15 @@ Rectangle {
width: sm ? Math.max(0, menuWidth - 40) : menuWidth
height: kioskHost ? 44 : (sm ? 30 : 34)
radius: sm ? 6 : theme.radiusSm
border.width: selectRoot.activeFocus ? 2 : (sm ? 0 : 1)
border.color: selectRoot.activeFocus ? theme.accent : theme.borderSubtle
// sm (toolbar) selects show NO focus ring: it lingered after a pick and
// read as "still modified", so the outline could never fully clear on a
// return to default. There, only outlineActive draws a border, and it is
// gone the instant the choice is the default. Settings-size selects keep
// the keyboard focus ring.
border.width: (selectRoot.activeFocus && !sm) ? 2
: (selectRoot.outlineActive ? 1 : (sm ? 0 : 1))
border.color: (selectRoot.activeFocus && !sm) || selectRoot.outlineActive
? theme.accent : theme.borderSubtle
// Resting fill goes translucent under the dynamic background so the field
// shows through the control (QbzSelect.slint:110-116). Hover keeps its own
// token, which is already translucent.
@@ -133,6 +158,9 @@ Rectangle {
return
selectRoot.filter = ""
listContent.currentIndex = selectRoot.currentIndex
// A field tooltip must not sit over its own open list.
if (selectRoot.tooltipHost)
selectRoot.tooltipHost.hide(selectRoot.tooltipKey !== "" ? selectRoot.tooltipKey : "qbz-select")
popup.open()
if (selectRoot.searchable)
searchInput.forceActiveFocus()
@@ -170,11 +198,28 @@ Rectangle {
anchors.leftMargin: selectRoot.sm ? 10 : 12
anchors.rightMargin: selectRoot.sm ? 8 : 10
spacing: 8
// Leading glyph (opt-in). A Row ignores a hidden child entirely — no
// slot, no spacing — so selects without a leadingIcon are unchanged.
QbzIcon {
id: leadingGlyph
visible: selectRoot.leadingIcon !== ""
name: selectRoot.leadingIcon
width: visible ? (selectRoot.sm ? 14 : 16) : 0
height: selectRoot.sm ? 14 : 16
anchors.verticalCenter: parent.verticalCenter
tintName: selectRoot.outlineActive ? "accent" : "secondary"
}
Text {
width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
width: parent.width - (leadingGlyph.visible ? leadingGlyph.width + 8 : 0)
- 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
height: parent.height
text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
? selectRoot.optLabel(selectRoot.currentIndex) : ""
// A placeholder select shows its fixed label; otherwise the current
// option. The active choice of a placeholder select is surfaced by
// the outline + tooltip, not here.
text: selectRoot.placeholderText !== ""
? selectRoot.placeholderText
: (selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
? selectRoot.optLabel(selectRoot.currentIndex) : "")
color: theme.textPrimary
font.pixelSize: selectRoot.kioskHost ? (selectRoot.sm ? 12 : theme.fontBody) * 1.2 : (selectRoot.sm ? 12 : theme.fontBody)
verticalAlignment: Text.AlignVCenter
@@ -207,7 +252,11 @@ Rectangle {
}
}
QbzIcon {
name: selectRoot.sortDirection !== ""
// A placeholder select keeps a NEUTRAL chevron collapsed — its
// direction is not surfaced in the field (it lives in the tooltip
// and the open list), so the field says nothing about the active
// sort. Non-placeholder selects keep the direction chevron.
name: (selectRoot.sortDirection !== "" && selectRoot.placeholderText === "")
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
: (selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down")
width: selectRoot.sm ? 14 : 16
@@ -227,6 +276,12 @@ Rectangle {
selectRoot.openPopup()
}
}
onContainsMouseChanged: {
if (selectRoot.tooltipHost && selectRoot.tooltipText !== "")
selectRoot.tooltipHost.hover(containsMouse && !popup.opened, selectRoot,
selectRoot.tooltipKey !== "" ? selectRoot.tooltipKey : "qbz-select",
selectRoot.tooltipText)
}
}
Popup {
+44 -9
View File
@@ -1372,8 +1372,10 @@ Rectangle {
// One menu row (MenuItem).
component AppMenuItem: Rectangle {
id: mi
property string name: ""
property string label: ""
property string shortcut: ""
property bool checkedItem: false
signal clicked()
@@ -1381,12 +1383,15 @@ Rectangle {
height: 34
color: miArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.left: parent.left
anchors.leftMargin: 12
anchors.rightMargin: 18
anchors.right: rightCluster.left
anchors.rightMargin: rightCluster.width > 0 ? 10 : 0
anchors.verticalCenter: parent.verticalCenter
height: parent.height
spacing: 10
QbzIcon {
name: parent.parent.name
name: mi.name
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
@@ -1394,19 +1399,32 @@ Rectangle {
}
Text {
id: miLabel
width: parent.width - 15 - parent.spacing
height: parent.height
text: parent.parent.label
text: mi.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Item {
visible: parent.parent.checkedItem
width: visible ? parent.width - 15 - miLabel.implicitWidth - 14 - 2 * parent.spacing : 0
height: 1
}
// Right cluster: the shortcut (muted + a size smaller than the
// label) floated right, then the optional check glyph.
Row {
id: rightCluster
anchors.right: parent.right
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
visible: mi.shortcut !== ""
text: mi.shortcut
color: theme.textMuted
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
QbzIcon {
visible: parent.parent.checkedItem
visible: mi.checkedItem
name: "check"
width: 14
height: 14
@@ -1430,6 +1448,7 @@ Rectangle {
AppMenuItem {
name: "settings-2"
label: QbzSession.tr("Settings", QbzSession.trRev)
shortcut: QbzHotkeys.shortcutFor("nav.settings")
onClicked: {
appMenu.close()
QbzShell.navigateTo("settings")
@@ -1440,6 +1459,7 @@ Rectangle {
AppMenuItem {
name: "link"
label: QbzSession.tr("Open Music Link", QbzSession.trRev)
shortcut: QbzHotkeys.shortcutFor("ui.openLink")
onClicked: {
appMenu.close()
QbzLink.show()
@@ -1448,6 +1468,7 @@ Rectangle {
AppMenuItem {
name: "keyboard"
label: QbzSession.tr("Keyboard Shortcuts", QbzSession.trRev)
shortcut: QbzHotkeys.shortcutFor("ui.showShortcuts")
onClicked: {
appMenu.close()
QbzHotkeys.openCheatsheet()
@@ -1508,6 +1529,7 @@ Rectangle {
AppMenuItem {
name: "x"
label: QbzSession.tr("Close", QbzSession.trRev)
shortcut: QbzHotkeys.formatShortcut("Ctrl+W")
// The same ONE close choreography as the drawn X above
// (A-26, §5.7). appMenu.close() FIRST, like the Log Out row:
// when close-to-tray only HIDES the window, a popup left open
@@ -1518,6 +1540,19 @@ Rectangle {
root.hostWindow.closeOrHide(null)
}
}
// Quit — the real exit (bypasses close-to-tray). Routes through the
// window's requestQuit (confirmation, session flush), same as
// Ctrl/Cmd+Q. Close above only closes/hides the window.
AppMenuItem {
name: "power"
label: QbzSession.tr("Quit", QbzSession.trRev)
shortcut: QbzHotkeys.formatShortcut("Ctrl+Q")
onClicked: {
appMenu.close()
if (root.hostWindow)
root.hostWindow.requestQuit()
}
}
}
}
}
@@ -309,21 +309,21 @@ Item {
id: kbCol
// The column model: an array of {label, rows} from groupsJson.
property var groups: []
spacing: 24
spacing: 16
Repeater {
model: kbCol.groups
delegate: Column {
id: kbGroup
width: kbCol.width
spacing: 12
spacing: 6
// `group` captured once: inside the nested rows Repeater,
// `modelData` rebinds to the ROW.
readonly property var group: modelData
Column {
width: parent.width
spacing: 8
spacing: 4
Text {
// VERBATIM label — NOT uppercased (trap 6).
text: kbGroup.group.label
@@ -342,10 +342,10 @@ Item {
model: kbGroup.group.rows
delegate: Rectangle {
width: kbGroup.width
// Slint :38: entry-row.preferred-height + 16 — the
// row content (the keycap is the tallest child) plus
// the layout's 8+8 padding, plus a further 16.
height: keycapLabel.implicitHeight + 40
// Compact rows: the keycap chip plus a tight
// vertical padding (was +40; owner asked for shorter,
// denser rows).
height: keycapLabel.implicitHeight + 20
radius: theme.radiusSm
color: theme.surfaceElevated
@@ -369,7 +369,7 @@ Item {
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: keycapLabel.implicitWidth + 16
height: keycapLabel.implicitHeight + 8
height: keycapLabel.implicitHeight + 6
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
+158 -9
View File
@@ -275,7 +275,20 @@ Rectangle {
var id = QbzPlayer.npTrackId
if (!/^[0-9]+$/.test(id))
return
trackInfo.openFor(id)
// A local/media-server track has a numeric library ROW id (not a Qobuz
// catalog id), so it cannot use the Qobuz Track Info modal — that one
// fed a local id rendered empty. Route it to the Local Media Info modal
// (source/server/format/paths). The (i) button and the song-card title
// both land here, so both are fixed.
var isLocal = false
try {
var q = JSON.parse(QbzQueue.queueJson)
isLocal = !!(q && q.current && q.current.isLocal)
} catch (e) {}
if (isLocal)
QbzLocal.openMediaInfoForTrack(parseInt(id, 10))
else
trackInfo.openFor(id)
}
TrackInfoModal { id: trackInfo }
@@ -306,13 +319,53 @@ Rectangle {
// LEFT-INSET by the docked-cover width so it begins right of the
// dock (AppShell.slint's large-active padding).
Item {
id: seekRow
width: parent.width
height: 32
// FLUIDITY (#660-reporter follow-up): the fill + handle used to
// track the BACKEND-reported npProgress, and a drag called
// QbzPlayer.seek() on every move. So the handle lagged the cursor
// by the seek round-trip and the backend was flooded with seeks —
// "slow and clunky, clicks not exact". Now a press starts a LOCAL
// scrub: the handle/fill follow the cursor instantly and ONE seek
// is committed on release (a plain click commits at the click
// point). `settling` holds the visual at the committed fraction
// until npProgress catches up so it never flashes back. The audio
// path and the seek call itself are unchanged.
property bool scrubbing: false
property bool settling: false
property real scrubFraction: 0
// Live hover position (fraction) for the time tooltip; -1 = none.
property real hoverFraction: -1
readonly property real visualProgress: (scrubbing || settling)
? scrubFraction
: Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
Timer {
id: settleTimeout
interval: 1500
onTriggered: seekRow.settling = false
}
Connections {
target: QbzPlayer
enabled: seekRow.settling
function onNpProgressChanged() {
if (Math.abs(QbzPlayer.npProgress - seekRow.scrubFraction) < 0.02) {
seekRow.settling = false
settleTimeout.stop()
}
}
}
Text {
x: root.dockWidth > 0 ? root.dockWidth + 6 : 6
anchors.verticalCenter: parent.verticalCenter
visible: QbzPlayer.npHasTrack
text: root.fmt(QbzPlayer.npElapsedSecs)
// While scrubbing, the elapsed readout follows the cursor.
text: (seekRow.scrubbing || seekRow.settling)
? root.fmt(seekRow.scrubFraction * QbzPlayer.npDurationSecs)
: root.fmt(QbzPlayer.npElapsedSecs)
color: theme.textMuted
font.pixelSize: 11
}
@@ -330,7 +383,7 @@ Rectangle {
anchors.fill: parent
visible: root.waveformVisible
values: QbzPlayer.npSeekWaveform
playedProgress: QbzPlayer.npProgress
playedProgress: seekRow.visualProgress
cacheProgress: QbzPlayer.npCacheProgress
baseColor: theme.surfaceElevated
cacheColor: Qt.rgba(theme.textMuted.r, theme.textMuted.g,
@@ -359,7 +412,7 @@ Rectangle {
}
Rectangle {
visible: !root.waveformVisible
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
width: parent.width * seekRow.visualProgress
height: parent.height
radius: 2
color: theme.accent
@@ -370,7 +423,7 @@ Rectangle {
height: 12
radius: 6
color: theme.textPrimary
x: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1) - width / 2
x: parent.width * seekRow.visualProgress - width / 2
anchors.verticalCenter: parent.verticalCenter
}
}
@@ -399,10 +452,106 @@ Rectangle {
? Qt.ArrowCursor
: (root.beyondSeekable(mouseX / width) ? Qt.ForbiddenCursor
: Qt.PointingHandCursor)
// Lock the seek target to what has downloaded while streaming
// (SeekBar.slint:96-99).
onPressed: if (QbzPlayer.npHasTrack) QbzPlayer.seek(root.seekTarget(mouseX / width))
onPositionChanged: if (pressed && QbzPlayer.npHasTrack) QbzPlayer.seek(root.seekTarget(mouseX / width))
id: seekArea
// A press starts a LOCAL scrub (the handle follows the cursor
// with zero round-trip lag); the real seek is committed ONCE on
// release — a plain click commits at the click point. seekTarget
// clamps to what has downloaded while streaming (SeekBar.slint:
// 96-99), so a scrub cannot pass the buffered edge.
onPressed: {
if (!QbzPlayer.npHasTrack)
return
seekRow.settling = false
seekRow.scrubbing = true
seekRow.scrubFraction = root.seekTarget(mouseX / width)
}
onPositionChanged: {
if (!QbzPlayer.npHasTrack)
return
if (pressed)
seekRow.scrubFraction = root.seekTarget(mouseX / width)
else
seekRow.hoverFraction = root.clamp01(mouseX / width)
}
onReleased: {
if (!seekRow.scrubbing)
return
seekRow.scrubbing = false
if (QbzPlayer.npHasTrack) {
// Hold the visual at the committed point until the
// backend position catches up, so it never flashes back.
seekRow.settling = true
settleTimeout.restart()
QbzPlayer.seek(seekRow.scrubFraction)
}
}
onCanceled: {
seekRow.scrubbing = false
seekRow.settling = false
}
onExited: seekRow.hoverFraction = -1
}
// Scrub / hover time bubble (best-effort parity with the web
// player). It floats above the rail with a downward caret. It is
// parented to the window OVERLAY, not to the bar: AppShell mounts
// the content frame AFTER the NowPlayingBar, so in ambient mode the
// frame's drop shadow drew OVER a bar-child tooltip (the "noise").
// The overlay is the top-most layer, above that shadow. Opaque fill
// for the same reason (a real blur needs a shader, which draws
// nothing on the software renderer).
Item {
id: seekTip
parent: Overlay.overlay
z: 10000
readonly property real frac: seekRow.scrubbing
? seekRow.scrubFraction
: seekRow.hoverFraction
readonly property color tipFill: theme.surfaceElevated
visible: QbzPlayer.npHasTrack && frac >= 0
&& (seekRow.scrubbing || seekArea.containsMouse)
width: bubble.width
height: bubble.height + caret.height
// Map the handle's top point into overlay coordinates so the
// caret still lands on the rail from the top layer. Re-maps when
// `frac` changes (the bar itself is static at the window foot).
readonly property point anchor: Overlay.overlay
? seekTrack.mapToItem(Overlay.overlay, seekTrack.width * frac, 0)
: Qt.point(0, 0)
x: Math.max(4, Math.min(
(Overlay.overlay ? Overlay.overlay.width : width) - width - 4,
anchor.x - width / 2))
// Caret tip sits right on the rail — no gap (removed per owner).
y: anchor.y - height + 3
Rectangle {
id: bubble
width: tipText.implicitWidth + 14
height: 18
radius: 4
color: seekTip.tipFill
border.width: 1
border.color: theme.borderMuted
Text {
id: tipText
anchors.centerIn: parent
text: root.fmt((seekTip.frac < 0 ? 0 : seekTip.frac) * QbzPlayer.npDurationSecs)
color: theme.textPrimary
font.pixelSize: 11
}
}
// Downward caret: a rotated square whose lower half shows below
// the bubble as the notch. Same fill, no border, so the bubble's
// border reads as one clean edge above it.
Rectangle {
id: caret
width: 9
height: 9
rotation: 45
color: seekTip.tipFill
anchors.horizontalCenter: bubble.horizontalCenter
y: bubble.height - height / 2
}
}
}
+42 -11
View File
@@ -250,8 +250,13 @@ Rectangle {
? QbzLibrary.sessionAlbumsHiresOnly : activeTab === "tracks"
? QbzLibrary.sessionTracksHiresOnly : false
function setHiresOnly(on) {
if (activeTab === "albums") QbzLibrary.sessionAlbumsHiresOnly = on
else if (activeTab === "tracks") QbzLibrary.sessionTracksHiresOnly = on
if (activeTab === "albums") {
QbzLibrary.sessionAlbumsHiresOnly = on
QbzLibrary.setLibraryPref("albumsHiresOnly", String(on))
} else if (activeTab === "tracks") {
QbzLibrary.sessionTracksHiresOnly = on
QbzLibrary.setLibraryPref("tracksHiresOnly", String(on))
}
}
// Only bumped when a live heart change can alter membership in the
// current Tracks / Albums source filter. Kept separate from `feed` so the
@@ -263,16 +268,33 @@ Rectangle {
property string artistsView: "grid" // "grid" | "sidepanel"
function setShowPurchases(on) {
if (activeTab === "albums") QbzLibrary.sessionAlbumsShowPurchases = on
else if (activeTab === "tracks") QbzLibrary.sessionTracksShowPurchases = on
else QbzLibrary.sessionShowPurchases = on
if (activeTab === "albums") {
QbzLibrary.sessionAlbumsShowPurchases = on
QbzLibrary.setLibraryPref("albumsShowPurchases", String(on))
} else if (activeTab === "tracks") {
QbzLibrary.sessionTracksShowPurchases = on
QbzLibrary.setLibraryPref("tracksShowPurchases", String(on))
} else {
QbzLibrary.sessionShowPurchases = on
QbzLibrary.setLibraryPref("allShowPurchases", String(on))
}
}
function setShowFavorites(on) {
if (activeTab === "albums") QbzLibrary.sessionAlbumsShowFavorites = on
else if (activeTab === "tracks") QbzLibrary.sessionTracksShowFavorites = on
else QbzLibrary.sessionShowFavorites = on
if (activeTab === "albums") {
QbzLibrary.sessionAlbumsShowFavorites = on
QbzLibrary.setLibraryPref("albumsShowFavorites", String(on))
} else if (activeTab === "tracks") {
QbzLibrary.sessionTracksShowFavorites = on
QbzLibrary.setLibraryPref("tracksShowFavorites", String(on))
} else {
QbzLibrary.sessionShowFavorites = on
QbzLibrary.setLibraryPref("allShowFavorites", String(on))
}
}
function setShowFollowing(on) {
QbzLibrary.sessionShowFollowing = on
QbzLibrary.setLibraryPref("allShowFollowing", String(on))
}
function setShowFollowing(on) { QbzLibrary.sessionShowFollowing = on }
// --- persisted toolbar choices (library_prefs.rs) --------------------
// Seeded from the bridge document rather than BOUND to it: these are
@@ -332,6 +354,7 @@ Rectangle {
/// Match the other session filters: false means no restriction.
function setShowLocal(on) {
QbzLibrary.sessionHideLocalAlbums = !on
QbzLibrary.setLibraryPref("allHideLocal", String(!on))
}
// Genre selections are independent for each Library tab, too.
@@ -394,8 +417,16 @@ Rectangle {
var asc = same ? !ReleaseSort.ascending(activeSort)
: ["default", "date", "updated", "duration", "track-count", "release-date"].indexOf(field) < 0
if (activeTab === "all") {
sortBy = field
sortAsc = asc
// "all" has no server "default" sort key of its own — its resting
// order IS date-added descending, so the explicit Default row maps
// back to exactly that.
if (field === "default") {
sortBy = "date"
sortAsc = false
} else {
sortBy = field
sortAsc = asc
}
} else {
setPref(activeTab + "Sort", field === "default"
? (asc ? "default-reverse" : "default") : field + (asc ? "-asc" : "-desc"))
@@ -22,7 +22,9 @@ Item {
var tab = view.activeTab
var options = []
function add(value, label) { options.push({value: value, label: label}) }
if (tab !== "all") add("default", QbzSession.tr("Default", QbzSession.trRev))
// Every tab now leads with an explicit Default (the resting order),
// so there is always a clear way back to the un-sorted state.
add("default", QbzSession.tr("Default", QbzSession.trRev))
add("date", QbzSession.tr("Date added", QbzSession.trRev))
if (tab === "playlists") add("updated", QbzSession.tr("Date updated", QbzSession.trRev))
add("title", QbzSession.tr("Alphabetical", QbzSession.trRev))
@@ -45,6 +47,24 @@ Item {
QbzTheme { id: theme }
// The active sort differs from the tab's resting default → the sort select
// highlights its outline and names the sort in a tooltip. "all" rests at
// date-added descending; every other tab rests at its "default" option.
readonly property bool sortIsNonDefault: {
if (view.activeTab === "all")
return !(view.sortBy === "date" && !view.sortAsc)
return ReleaseSort.fieldOf(view.activeSort) !== "default"
}
readonly property string sortActiveTooltip: {
if (!root.sortIsNonDefault) return ""
var field = ReleaseSort.fieldOf(view.activeSort)
var lbl = ""
for (var i = 0; i < root.sortOptions.length; i++)
if (root.sortOptions[i].value === field) { lbl = root.sortOptions[i].label; break }
if (lbl === "") return ""
return lbl + (ReleaseSort.ascending(view.activeSort) ? " ↑" : " ↓")
}
readonly property bool singleRow: view.activeTab === "labels" || view.activeTab === "playlists"
|| view.activeTab === "artists"
readonly property real rowGap: 8
@@ -89,8 +109,14 @@ Item {
width: gtbRow.implicitWidth
height: 30
radius: 6
color: gtb.active ? theme.accent
: gtbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
// Homologated with the selects (QbzSelect): the same ambient-aware
// rest fill and, when a genre is applied, the SAME accent OUTLINE —
// not an accent fill — so the three toolbar controls read identically
// in their modified state.
color: gtbArea.containsMouse ? theme.surfaceHover
: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
border.width: gtb.active ? 1 : 0
border.color: gtb.active ? theme.accent : "transparent"
Row {
id: gtbRow
anchors.centerIn: parent
@@ -112,7 +138,7 @@ Item {
// on ikari, 1.82 on wcag-dark and under 2.6:1 on 16 of the
// 35 palettes — deliberate divergence from both lines.
// theme/QbzTheme.qml, "ON AN ACCENT FILL".
tintName: gtb.active ? theme.accentGlyphTint : "secondary"
tintName: gtb.active ? "accent" : "secondary"
}
Text {
visible: true
@@ -125,7 +151,7 @@ Item {
.replace("{}", root.view.genreCount)
// The colour twin of the glyph's tint above — accent-text on
// 34 of the 35 palettes.
color: gtb.active ? theme.accentGlyphColor : theme.textSecondary
color: gtb.active ? theme.accent : theme.textSecondary
font.pixelSize: 12
}
}
@@ -168,79 +194,27 @@ Item {
// own pref. The reference draws a QbzSelect per tab; this is the same menu
// shape the sort popups beside it already use, so the toolbar stays
// visually of a piece.
component GroupSelect: Rectangle {
// Now a QbzSelect (sm) like the sort control beside it: one select style,
// ambient-aware, with the same popup. `options` are {value,label} — the
// primitive reads `.label`; the value maps back through the picked index.
// The outline highlights once a real grouping (not the resting "off") is on.
component GroupSelect: QbzSelect {
id: gsRoot
property var options: [] // [{value, label}]
property string current: "off"
signal picked(string value)
visible: false
width: gsRow.width
height: 30
radius: 6
color: gsArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
readonly property string currentLabel: {
for (var i = 0; i < options.length; i++)
if (options[i].value === current) return options[i].label
return ""
sm: true
menuWidth: 180
popupWidth: 180
currentIndex: {
for (var i = 0; i < gsRoot.options.length; i++)
if (gsRoot.options[i].value === gsRoot.current) return i
return 0
}
Row {
id: gsRow
height: parent.height
leftPadding: 10
rightPadding: 10
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: gsRoot.currentLabel
color: theme.textSecondary
font.pixelSize: 12
}
QbzIcon {
name: "chevron-down"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
}
MouseArea {
id: gsArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: gsMenu.openBelowRight(gsArea)
}
QbzContextMenu {
id: gsMenu
menuWidth: 156
Repeater {
model: gsRoot.options
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: gsOptArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
anchors.fill: parent
anchors.leftMargin: 8
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: gsRoot.current === modelData.value
? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
MouseArea {
id: gsOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { gsRoot.picked(modelData.value); gsMenu.close() }
}
}
}
outlineActive: gsRoot.current !== "off"
onSelected: function (index) {
if (index >= 0 && index < gsRoot.options.length)
gsRoot.picked(gsRoot.options[index].value)
}
}
@@ -340,11 +314,26 @@ Item {
QbzSelect {
id: sortSelect
sm: true
menuWidth: 190
// Collapsed control is a compact "Sort" pill; the list stays wide.
menuWidth: 140
popupWidth: 200
leadingIcon: "arrow-down-up"
placeholderText: QbzSession.tr("Sort", QbzSession.trRev)
outlineActive: root.sortIsNonDefault
tooltipHost: libTips
tooltipText: root.sortActiveTooltip
tooltipKey: "library-sort"
options: root.sortOptions
searchable: options.length > 8
currentIndex: {
// On "all" the resting order is date-desc; map that to the
// explicit Default row so the list highlights it (and the
// outline stays off) at the default.
if (root.view.activeTab === "all"
&& root.view.sortBy === "date" && !root.view.sortAsc) {
for (var j = 0; j < options.length; ++j)
if (options[j].value === "default") return j
}
var field = ReleaseSort.fieldOf(root.view.activeSort)
for (var i = 0; i < options.length; ++i)
if (options[i].value === field) return i
@@ -357,6 +346,7 @@ Item {
visible: root.view.activeTab === "playlists"
sm: true
menuWidth: 180
outlineActive: root.view.playlistsSubTab !== "all"
options: [QbzSession.tr("All", QbzSession.trRev),
QbzSession.tr("By you", QbzSession.trRev),
QbzSession.tr("By Qobuz", QbzSession.trRev),
@@ -657,4 +647,13 @@ Item {
return root.view.activeTab === "all" ? root.view.search : root.view.tabSearch
})
}
// Shared hover-tooltip overlay for this toolbar (the sort control names its
// active sort here when it differs from the tab default). Mounted last so
// it paints above the controls; QueueView uses the same pattern.
QbzTooltip {
id: libTips
anchors.fill: parent
z: 4000
}
}
+22
View File
@@ -132,6 +132,16 @@ pub mod qbz_hotkeys {
#[qinvokable]
fn refresh(self: Pin<&mut QbzHotkeys>);
/// Formatted, platform-aware shortcut for an action id (⌘ on macOS,
/// user overrides applied) — the header app-menu rows read this so they
/// never hardcode a per-OS combo. Empty for an unbound / unknown id.
#[qinvokable]
fn shortcut_for(self: Pin<&mut QbzHotkeys>, id: QString) -> QString;
/// Format a canonical shortcut ("Ctrl+W") for THIS platform — for the
/// window verbs (close/quit) that live outside the action table.
#[qinvokable]
fn format_shortcut(self: Pin<&mut QbzHotkeys>, canonical: QString) -> QString;
/// nav.search (Ctrl+f) — divergence K6 / contract §4.3: the AppShell
/// Connections listens and calls HeaderBar.focusSearch()
/// (`searchInput.forceActiveFocus()`, HeaderBar.qml:644). EXCEEDS
@@ -530,6 +540,18 @@ impl qbz_hotkeys::QbzHotkeys {
publish_groups(self.as_mut());
}
pub fn shortcut_for(self: Pin<&mut Self>, id: QString) -> QString {
let keymap = crate::hotkeys_qt::active_keymap();
let overrides = crate::hotkeys_qt::load_overrides();
let bindings = crate::hotkeys_qt::active_bindings_with(keymap, &overrides);
let shortcut = bindings.get(&id.to_string()).cloned().unwrap_or_default();
QString::from(crate::hotkeys_qt::format_display(&shortcut).as_str())
}
pub fn format_shortcut(self: Pin<&mut Self>, canonical: QString) -> QString {
QString::from(crate::hotkeys_qt::format_display(&canonical.to_string()).as_str())
}
pub fn open_customize(mut self: Pin<&mut Self>) {
self.as_mut().set_customize_open(true);
publish_groups(self.as_mut());
+27 -7
View File
@@ -813,6 +813,21 @@ pub fn build_groups(keymap: Keymap, overrides: &BTreeMap<String, String>) -> Vec
contextual: a.context != Context::None,
});
}
// The window verbs (close/quit) are handled by the native lifecycle
// layer, not the hotkey dispatcher, but they belong in the cheatsheet's
// Navigation list. Display-only rows: not in ACTIONS, so `action_for_key`
// never claims them and the customize editor never offers a rebind.
if cat == Category::Navigation {
for (label_en, canonical) in [("Close Window", "Ctrl+W"), ("Quit", "Ctrl+Q")] {
rows.push(GroupRow {
id: String::new(),
label: qbz_i18n::t(label_en),
shortcut: format_display(canonical),
modified: false,
contextual: false,
});
}
}
groups.push(Group {
label: qbz_i18n::t(cat.label_en()),
rows,
@@ -839,9 +854,13 @@ pub fn modified_count_with(keymap: Keymap, overrides: &BTreeMap<String, String>)
/// never "{}").
pub fn groups_json(keymap: Keymap, overrides: &BTreeMap<String, String>) -> String {
let groups = build_groups(keymap, overrides);
// Fill columns SEQUENTIALLY (ceil per column) rather than round-robin, so
// the blocks read in category order top-to-bottom, left-to-right — which is
// what puts Immersive directly under Interface.
let per = groups.len().div_ceil(3).max(1);
let mut cols: [Vec<Group>; 3] = Default::default();
for (i, g) in groups.into_iter().enumerate() {
cols[i % 3].push(g);
cols[(i / per).min(2)].push(g);
}
let [c0, c1, c2] = cols;
serde_json::json!({ "col1": c0, "col2": c1, "col3": c2 }).to_string()
@@ -1324,7 +1343,7 @@ mod tests {
// --- Round-robin split {Playback,Immersive}/{Navigation,Mini}/{Interface}
#[test]
fn groups_split_round_robin_into_three_columns() {
fn groups_split_sequentially_into_three_columns() {
let doc: serde_json::Value = serde_json::from_str(&groups_json(Keymap::Default, &no_overrides())).unwrap();
let labels = |col: &str| -> Vec<String> {
doc[col]
@@ -1334,9 +1353,9 @@ mod tests {
.map(|g| g["label"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(labels("col1"), vec!["Playback", "Immersive"]);
assert_eq!(labels("col2"), vec!["Navigation", "Mini Player"]);
assert_eq!(labels("col3"), vec!["Interface"]);
assert_eq!(labels("col1"), vec!["Playback", "Navigation"]);
assert_eq!(labels("col2"), vec!["Interface", "Immersive"]);
assert_eq!(labels("col3"), vec!["Mini Player"]);
}
#[test]
@@ -1348,8 +1367,9 @@ mod tests {
assert_eq!(row["shortcut"], "Space");
assert_eq!(row["modified"], false);
assert_eq!(row["contextual"], false);
// The immersive seek rows are contextual.
let seek = &doc["col1"][1]["rows"][0];
// The immersive seek rows are contextual. Sequential fill puts the
// Immersive block second in column 2 (under Interface).
let seek = &doc["col2"][1]["rows"][0];
assert_eq!(seek["id"], "focus.seekForward");
assert_eq!(seek["contextual"], true);
// Full shape, never "{}" (trap 15).
+2
View File
@@ -156,6 +156,7 @@ pub struct QbzIconTintRust {
const MASTERS: &[(&str, &str)] = &[
("add-to-list", include_str!("../qml/assets/icons/primary/add-to-list.svg")),
("align-start-vertical", include_str!("../qml/assets/icons/primary/align-start-vertical.svg")),
("arrow-down-up", include_str!("../qml/assets/icons/primary/arrow-down-up.svg")),
("arrow-up-down", include_str!("../qml/assets/icons/primary/arrow-up-down.svg")),
("audio-lines", include_str!("../qml/assets/icons/primary/audio-lines.svg")),
("award", include_str!("../qml/assets/icons/primary/award.svg")),
@@ -261,6 +262,7 @@ const MASTERS: &[(&str, &str)] = &[
("pin", include_str!("../qml/assets/icons/primary/pin.svg")),
("play-fill", include_str!("../qml/assets/icons/primary/play-fill.svg")),
("plus", include_str!("../qml/assets/icons/primary/plus.svg")),
("power", include_str!("../qml/assets/icons/primary/power.svg")),
("qbz-symbolic", include_str!("../qml/assets/icons/primary/qbz-symbolic.svg")),
("radio", include_str!("../qml/assets/icons/primary/radio.svg")),
("refresh-cw", include_str!("../qml/assets/icons/primary/refresh-cw.svg")),
+20
View File
@@ -241,6 +241,26 @@ impl qbz_library_bridge::QbzLibrary {
let prefs = crate::library_prefs::to_json();
self.as_mut()
.set_library_prefs_json(QString::from(prefs.as_str()));
// Seed the per-tab source filters from disk too (persisted now, so a
// chosen filter survives a restart — still independent per tab). These
// are separate qproperties from the toolbar json doc above; done once
// here rather than in the reactive QML applyPrefs so a later document
// republish can never re-seed over a live toggle.
let f = crate::library_prefs::read();
self.as_mut().set_session_show_purchases(f.all_show_purchases);
self.as_mut().set_session_show_favorites(f.all_show_favorites);
self.as_mut().set_session_show_following(f.all_show_following);
self.as_mut().set_session_hide_local_albums(f.all_hide_local);
self.as_mut()
.set_session_albums_show_purchases(f.albums_show_purchases);
self.as_mut()
.set_session_albums_show_favorites(f.albums_show_favorites);
self.as_mut().set_session_albums_hires_only(f.albums_hires_only);
self.as_mut()
.set_session_tracks_show_purchases(f.tracks_show_purchases);
self.as_mut()
.set_session_tracks_show_favorites(f.tracks_show_favorites);
self.as_mut().set_session_tracks_hires_only(f.tracks_hires_only);
}
pub fn set_library_pref(mut self: Pin<&mut Self>, key: QString, value: QString) {
+55
View File
@@ -61,6 +61,31 @@ pub struct Prefs {
/// or "all" (the entire local library + Plex).
#[serde(default = "d_favorites")]
pub all_local_scope: String,
// Per-tab source filters (were session-only; persisted now so a chosen
// filter survives a restart, still independent per tab). Qt-only keys —
// the frozen Slint document never had them, and serde defaults keep an
// older file readable.
#[serde(default)]
pub all_show_purchases: bool,
#[serde(default)]
pub all_show_favorites: bool,
#[serde(default)]
pub all_show_following: bool,
#[serde(default)]
pub all_hide_local: bool,
#[serde(default)]
pub albums_show_purchases: bool,
#[serde(default)]
pub albums_show_favorites: bool,
#[serde(default)]
pub albums_hires_only: bool,
#[serde(default)]
pub tracks_show_purchases: bool,
#[serde(default)]
pub tracks_show_favorites: bool,
#[serde(default)]
pub tracks_hires_only: bool,
}
fn d_grid() -> String {
@@ -95,6 +120,16 @@ impl Default for Prefs {
artists_view: d_grid(),
all_show_local: true,
all_local_scope: d_favorites(),
all_show_purchases: false,
all_show_favorites: false,
all_show_following: false,
all_hide_local: false,
albums_show_purchases: false,
albums_show_favorites: false,
albums_hires_only: false,
tracks_show_purchases: false,
tracks_show_favorites: false,
tracks_hires_only: false,
}
}
}
@@ -135,6 +170,16 @@ pub fn to_json() -> String {
"artistsView": p.artists_view,
"allShowLocal": p.all_show_local,
"allLocalScope": p.all_local_scope,
"allShowPurchases": p.all_show_purchases,
"allShowFavorites": p.all_show_favorites,
"allShowFollowing": p.all_show_following,
"allHideLocal": p.all_hide_local,
"albumsShowPurchases": p.albums_show_purchases,
"albumsShowFavorites": p.albums_show_favorites,
"albumsHiresOnly": p.albums_hires_only,
"tracksShowPurchases": p.tracks_show_purchases,
"tracksShowFavorites": p.tracks_show_favorites,
"tracksHiresOnly": p.tracks_hires_only,
})
.to_string()
}
@@ -206,6 +251,16 @@ pub fn set(key: &str, value: &str) {
"artistsView" => update(|p| p.artists_view = v),
"allShowLocal" => update(|p| p.all_show_local = v == "true"),
"allLocalScope" => update(|p| p.all_local_scope = normalize_scope(&v).to_string()),
"allShowPurchases" => update(|p| p.all_show_purchases = v == "true"),
"allShowFavorites" => update(|p| p.all_show_favorites = v == "true"),
"allShowFollowing" => update(|p| p.all_show_following = v == "true"),
"allHideLocal" => update(|p| p.all_hide_local = v == "true"),
"albumsShowPurchases" => update(|p| p.albums_show_purchases = v == "true"),
"albumsShowFavorites" => update(|p| p.albums_show_favorites = v == "true"),
"albumsHiresOnly" => update(|p| p.albums_hires_only = v == "true"),
"tracksShowPurchases" => update(|p| p.tracks_show_purchases = v == "true"),
"tracksShowFavorites" => update(|p| p.tracks_show_favorites = v == "true"),
"tracksHiresOnly" => update(|p| p.tracks_hires_only = v == "true"),
other => log::warn!("[qbz-qt] library pref: unknown key '{other}' (value '{value}')"),
}
}
+9
View File
@@ -380,6 +380,11 @@ pub mod qbz_local {
fn close_media_info(self: Pin<&mut QbzLocal>);
#[qinvokable]
fn copy_media_info(self: Pin<&mut QbzLocal>, value: QString);
/// Open the local media-info modal for a now-playing local track by its
/// library row id (the NPB info button / song-card title, which have no
/// Local Library model row to act on).
#[qinvokable]
fn open_media_info_for_track(self: Pin<&mut QbzLocal>, id: i64);
/// Row body: select a folder and load its detail pane.
#[qinvokable]
fn select_folder(self: Pin<&mut QbzLocal>, path: QString);
@@ -1768,6 +1773,10 @@ impl qbz_local::QbzLocal {
crate::local_media_info_qt::copy(value.to_string());
}
pub fn open_media_info_for_track(self: Pin<&mut Self>, id: i64) {
crate::local_media_info_qt::open_track_by_row_id(id);
}
// --- Local album actions -------------------------------------------------
pub fn clear_pending_artist(self: Pin<&mut Self>) {
+23
View File
@@ -337,6 +337,29 @@ pub fn open_empty(kind: &'static str) {
publish(aggregate_doc(kind, &[]));
}
/// Open the media-info modal for a local track resolved from its library row
/// id. The now-playing-bar info button and the song-card title only have the
/// track id (not a Local Library model row), so they route here instead of the
/// Qobuz Track Info modal — which, fed a local row id, rendered empty (#local
/// track info). Opens the loading state at once, then fills it off the UI
/// thread; a row that no longer resolves shows the empty "track" card rather
/// than the wrong modal.
pub fn open_track_by_row_id(row_id: i64) {
begin();
crate::spawn(async move {
let track = tokio::task::spawn_blocking(move || {
crate::local_playback::find_track_blocking(row_id)
})
.await
.ok()
.flatten();
match track {
Some(track) => open_track(track),
None => open_empty("track"),
}
});
}
pub fn close() {
crate::local_bridge::ui(|mut bridge| {
bridge.as_mut().set_local_media_info_open(false);
+1 -1
View File
@@ -739,7 +739,7 @@ pub async fn play_tracks_visible(runtime: &Runtime, visible_ids_json: String, cl
/// Look up ONE raw row by id: the loaded Tracks page first, then the open
/// detail pane, then `library.db` (Plex ids never reach the DB — they are
/// namespaced and only ever live in the cached documents).
fn find_track_blocking(row_id: i64) -> Option<LocalTrack> {
pub(crate) fn find_track_blocking(row_id: i64) -> Option<LocalTrack> {
let cached = state(|s| {
s.tracks_raw
.iter()