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
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 |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}')"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||