fix(qbz-qt): row buttons, cover triggers, light-theme contrast, flyouts, genre filter
**Track-row buttons did nothing.** Declaration order is z-order in QML, and the row-wide MouseArea added for right-click was declared last — so it covered the heart, cloud and menu buttons and swallowed their presses. propagateComposedEvents cannot rescue that: a press is not a composed event, which is why only the row's own double-click still worked. **Covers appeared only after a scroll.** Three compounding defects, not one. The window report fired on contentY and on model change, both of which happen while the tab body is still hidden behind its loading flag, so the report that mattered hit the not-visible early return and was discarded with nothing to re-fire it. The debounce reset on every change, so a burst of mount-time reports kept pushing its trailing edge out. And a single pending slot meant that when two surfaces are mounted at once — the artist rail and that artist's grid — the second overwrote the first, so one pane was never requested at all. Eviction had the same shape: each report deleted everything outside its own array, so the two panes deleted each other's covers. **Latest release had no cover** because the artist page's collector skipped it, along with Appears On and the playlists strip. **Light themes.** The mechanism is Slint's, from 2.0.2 and issue #638: the on-surface arm on circle actions, header-light deciding the header foreground only when a dark backdrop is actually painted, and the circular backing behind the track-row play glyph. Ported as specified rather than approximated. Two icons rendered in their SVG's own colour because they pointed at the raw asset instead of a baked tint — a sweep found the rest of that class. **Navigation flyouts** exist, one component serving both the sidebar and the header nav modes. **Filter by genre and the Discover sort now work**, everywhere the control is drawn. The genre ids were already a parameter on the discover fetch that the port always passed empty. cc
This commit is contained in:
52 files changed
+3557
-264
No files matched your search
@@ -18,6 +18,12 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Bakes matching the LIVE tokens (TransportControls.slint's fav-ta:
|
||||
// hover -> Theme.text-primary, idle -> Theme.text-secondary). Hardcoded
|
||||
// bake names are dark-theme-only colours — see QbzIconButton's header.
|
||||
readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
|
||||
readonly property string tintWeak: theme.textSecondary.hslLightness > 0.5 ? "secondary" : "muted"
|
||||
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
@@ -30,7 +36,7 @@ Rectangle {
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: root.favorite ? "favorite"
|
||||
: (favArea.containsMouse ? "primary" : "secondary")
|
||||
: (favArea.containsMouse ? root.tintStrong : root.tintWeak)
|
||||
}
|
||||
MouseArea {
|
||||
id: favArea
|
||||
|
||||
@@ -46,6 +46,14 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// The section-nav dropdown (catalog + panel + open/close behaviour).
|
||||
// Zero-size and NOT inside any Row — its origin must stay this root's
|
||||
// origin, because the trigger coordinates are mapped into it. The panel
|
||||
// itself renders in the window overlay, so it is not clipped by the 42px
|
||||
// header (Slint solves the same problem by mounting HeaderMenuOverlay as
|
||||
// the last child of the AppShell root).
|
||||
NavFlyout { id: navFlyout }
|
||||
|
||||
// Custom-chrome drag surface (declared FIRST so every interactive
|
||||
// element above wins hit-testing): press-and-move starts the system
|
||||
// move; double-click toggles maximize. The system grab starts only
|
||||
@@ -78,29 +86,20 @@ Rectangle {
|
||||
: QbzSession.offlineMode === 1 ? 1 : 0
|
||||
|
||||
// --- Section nav (shared by both header forms) ------------------------
|
||||
// Same four sections Sidebar.qml lists, in the same order. Slint keeps two
|
||||
// copies of this list too (Sidebar.slint:724 and HeaderBar.slint:858/974)
|
||||
// — the alternative here would be a new singleton .qml file, which needs a
|
||||
// build.rs entry; the list is six lines, so it stays local.
|
||||
// The catalog (sections, their entries, icons and order) and the flyout
|
||||
// panel itself now live in ONE place, NavFlyout.qml — mounted above as
|
||||
// `navFlyout`, and mounted the same way by Sidebar.qml. Slint duplicates
|
||||
// the same literals three times (Sidebar.slint:724, HeaderBar.slint:858
|
||||
// full tabs, :974 compact buttons); the port does not.
|
||||
//
|
||||
// `view` is the route QbzShell.navigateTo takes. My QBZ has NO view in
|
||||
// this port (AppShell's loader maps home/library/local/localalbum/album/
|
||||
// artist/settings/search/playlist), so it renders disabled rather than as
|
||||
// a live control that clicks into nothing — same call Sidebar.qml makes.
|
||||
readonly property var navSections: [
|
||||
{ "id": "discover", "icon": "compass", "label": QbzSession.tr("Discover", QbzSession.trRev), "view": "home", "qobuz": true, "enabled": true },
|
||||
{ "id": "library", "icon": "music-library-2", "label": QbzSession.tr("Library", QbzSession.trRev), "view": "library", "qobuz": true, "enabled": true },
|
||||
{ "id": "local", "icon": "hard-drive", "label": QbzSession.tr("Local Library", QbzSession.trRev), "view": "local", "qobuz": false, "enabled": true },
|
||||
{ "id": "myqbz", "icon": "qbz-symbolic", "label": QbzSession.tr("My QBZ", QbzSession.trRev), "view": "", "qobuz": false, "enabled": false }
|
||||
]
|
||||
// Triggers no longer navigate: like their Slint counterparts they OPEN the
|
||||
// section dropdown (on hover and on click), and the ENTRIES navigate.
|
||||
readonly property var navSections: navFlyout.sections
|
||||
|
||||
// Highlighted section — derived from the live view, exactly like
|
||||
// Sidebar.qml (see the note there on why this is not click-set state).
|
||||
readonly property string activeNav:
|
||||
QbzShell.currentView === "home" ? "discover"
|
||||
: QbzShell.currentView === "library" ? "library"
|
||||
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
|
||||
: ""
|
||||
// Highlighted section — derived from the live view (see NavFlyout), OR'd
|
||||
// in the triggers with "my menu is open" (Slint highlights off
|
||||
// HeaderMenuState.open-index).
|
||||
readonly property string activeNav: navFlyout.activeSection
|
||||
|
||||
// Which of the two header forms is mounted (mutually exclusive, and both
|
||||
// off while the nav lives in the sidebar and the sidebar is not closed).
|
||||
@@ -116,7 +115,8 @@ Rectangle {
|
||||
id: navTab
|
||||
property var section: null
|
||||
property bool showIcon: true
|
||||
readonly property bool isActive: section && root.activeNav === section.id
|
||||
readonly property bool isActive: section
|
||||
&& (root.activeNav === section.id || navFlyout.openId === section.id)
|
||||
readonly property bool isEnabled: section && section.enabled
|
||||
|
||||
height: 30
|
||||
@@ -155,7 +155,21 @@ Rectangle {
|
||||
enabled: navTab.isEnabled
|
||||
hoverEnabled: navTab.isEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.navigateTo(navTab.section.view)
|
||||
// Full text tabs name their section already, so the dropdown gets
|
||||
// no title row (HeaderBar.slint:167 sets title: "").
|
||||
onClicked: navFlyout.openUnder(navTab, navTab.section, false)
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) {
|
||||
// Hover-to-open, and hovering a DIFFERENT tab overwrites
|
||||
// the open menu — instant switch, single-open by design.
|
||||
navFlyout.triggerHovered = true
|
||||
navFlyout.openUnder(navTab, navTab.section, false)
|
||||
} else if (navTab.section && navFlyout.openId === navTab.section.id) {
|
||||
// Only the trigger owning the open menu clears the flag
|
||||
// (avoids a leave/enter race between adjacent tabs).
|
||||
navFlyout.triggerHovered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,8 @@ Rectangle {
|
||||
component CompactNavBtn: Rectangle {
|
||||
id: cnb
|
||||
property var section: null
|
||||
readonly property bool isActive: section && root.activeNav === section.id
|
||||
readonly property bool isActive: section
|
||||
&& (root.activeNav === section.id || navFlyout.openId === section.id)
|
||||
readonly property bool isEnabled: section && section.enabled
|
||||
|
||||
width: 30
|
||||
@@ -186,7 +201,17 @@ Rectangle {
|
||||
enabled: cnb.isEnabled
|
||||
hoverEnabled: cnb.isEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzShell.navigateTo(cnb.section.view)
|
||||
// Icon-only buttons do NOT name their section, so their dropdown
|
||||
// is headed by the section name + hairline (HeaderBar.slint:223).
|
||||
onClicked: navFlyout.openUnder(cnb, cnb.section, true)
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) {
|
||||
navFlyout.triggerHovered = true
|
||||
navFlyout.openUnder(cnb, cnb.section, true)
|
||||
} else if (cnb.section && navFlyout.openId === cnb.section.id) {
|
||||
navFlyout.triggerHovered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
// Section-nav dropdown flyout — ONE component, mounted by BOTH nav hosts
|
||||
// (HeaderBar.qml and Sidebar.qml). The QML port of Slint's
|
||||
// crates/qbz-ui/ui/shell/HeaderMenuOverlay.slint + NavMenuItem.slint, plus
|
||||
// the entry catalog the Slint tree duplicates three times (Sidebar.slint:724,
|
||||
// HeaderBar.slint:858 full tabs, HeaderBar.slint:974 compact buttons).
|
||||
//
|
||||
// WHY ONE FILE OWNS THE CATALOG TOO: Slint keeps three copies of the same
|
||||
// [NavMenuEntry] literals because its `if` mount sites cannot share an array
|
||||
// expression. QML can, so `sections` lives here and both hosts read it —
|
||||
// there is exactly one place to add a section or an entry.
|
||||
//
|
||||
// WHY A Popup: Slint mounts HeaderMenuOverlay as the LAST child of the
|
||||
// AppShell root because a menu hosted inside the 42px header would be clipped
|
||||
// by the content area (and a PopupWindow would grab the pointer, killing
|
||||
// hover-switching between tabs). QtQuick.Controls' Popup reparents its
|
||||
// content to the window overlay, which gives the same "above everything, not
|
||||
// clipped by my host" placement WITHOUT touching AppShell.qml — and a
|
||||
// non-modal Popup does not grab hover, so hover-switching works. The
|
||||
// closed-sidebar playlists flyout in HeaderBar.qml already uses this pattern.
|
||||
//
|
||||
// Open/close behaviour is 1:1 with the Slint overlay:
|
||||
// - hovering OR clicking a trigger opens that section's menu;
|
||||
// - hovering a different trigger overwrites the open menu (instant switch,
|
||||
// single-open is automatic — there is one panel);
|
||||
// - the menu closes after 1s idle, and the countdown is PAUSED while the
|
||||
// pointer rests on the trigger or anywhere on the panel (panel body OR a
|
||||
// row — hence the two-flag OR, HeaderMenuOverlay.slint:41);
|
||||
// - a press outside, or Escape, closes it.
|
||||
//
|
||||
// GAP vs Slint: no drop shadow. Qt's shadow effects (Qt5Compat /
|
||||
// QtQuick.Effects MultiEffect) render NOTHING on the software path this port
|
||||
// runs on (see RoundedImage.qml / TrackInfoModal.qml) — the 1px border-muted
|
||||
// outline carries the separation instead.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: nav
|
||||
// Zero-size, non-layout host: it exists only to give the Popup a
|
||||
// coordinate system (the host root's) and to carry the shared catalog.
|
||||
// Mount it as a DIRECT child of the host root, never inside a Row/Column,
|
||||
// so its origin stays the host root's origin.
|
||||
width: 0
|
||||
height: 0
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Catalog
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Slint gates the Recommendations entry on SettingsState.show-recommendations
|
||||
// (discover_prefs `show_recommendations`, default ON) — the port publishes
|
||||
// it in QbzBridge.settingsJson.
|
||||
readonly property bool showRecommendations: {
|
||||
try {
|
||||
return JSON.parse(QbzBridge.settingsJson).showRecommendations !== false
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// The four top-level sections, in Slint order. `rev` is threaded through
|
||||
// buildSections purely so the binding re-runs on a language switch
|
||||
// (QbzSession.trRev is the translation revision counter every other
|
||||
// tr() call site binds to).
|
||||
readonly property var sections: buildSections(QbzSession.trRev, showRecommendations)
|
||||
|
||||
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
// Entry shape: { label, icon (baked glyph name, "" = no glyph),
|
||||
// view (shell route), tab (view-internal tab id),
|
||||
// enabled }
|
||||
// Section shape: { id, icon, label, qobuz (hidden while offline),
|
||||
// enabled, entries }
|
||||
function buildSections(rev, reco) {
|
||||
// Discover — glyph-less rows (Slint has-glyph: false).
|
||||
var discover = [
|
||||
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
|
||||
{ "label": nav.trs("Editor's Picks"), "icon": "", "view": "home", "tab": "editorPicks", "enabled": true },
|
||||
{ "label": nav.trs("For You"), "icon": "", "view": "home", "tab": "forYou", "enabled": true }
|
||||
]
|
||||
if (reco) {
|
||||
discover.push({ "label": nav.trs("Recommendations"), "icon": "", "view": "home", "tab": "recommendations", "enabled": true })
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": "discover",
|
||||
"icon": "compass",
|
||||
"label": nav.trs("Discover"),
|
||||
"qobuz": true,
|
||||
"enabled": true,
|
||||
"entries": discover
|
||||
},
|
||||
{
|
||||
"id": "library",
|
||||
"icon": "music-library-2",
|
||||
"label": nav.trs("Library"),
|
||||
"qobuz": true,
|
||||
"enabled": true,
|
||||
"entries": [
|
||||
{ "label": nav.trs("All"), "icon": "layout-grid", "view": "library", "tab": "all", "enabled": true },
|
||||
{ "label": nav.trs("Tracks"), "icon": "music", "view": "library", "tab": "tracks", "enabled": true },
|
||||
{ "label": nav.trs("Albums"), "icon": "disc", "view": "library", "tab": "albums", "enabled": true },
|
||||
{ "label": nav.trs("Artists"), "icon": "user", "view": "library", "tab": "artists", "enabled": true },
|
||||
{ "label": nav.trs("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "enabled": true },
|
||||
{ "label": nav.trs("Labels"), "icon": "disc-3", "view": "library", "tab": "labels", "enabled": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "local",
|
||||
"icon": "hard-drive",
|
||||
"label": nav.trs("Local Library"),
|
||||
"qobuz": false,
|
||||
"enabled": true,
|
||||
"entries": [
|
||||
{ "label": nav.trs("Albums"), "icon": "disc", "view": "local", "tab": "albums", "enabled": true },
|
||||
{ "label": nav.trs("Artists"), "icon": "user", "view": "local", "tab": "artists", "enabled": true },
|
||||
{ "label": nav.trs("Folders"), "icon": "folder", "view": "local", "tab": "folders", "enabled": true },
|
||||
{ "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
// GAP: this port has NO My QBZ surface — the shell router maps
|
||||
// home / library / local / localalbum / album / artist /
|
||||
// settings / search / playlist, and neither Collections nor
|
||||
// Mixtapes has a view behind it. The whole section is disabled
|
||||
// (dimmed, pointer-inert, no flyout) rather than opening a menu
|
||||
// whose every row clicks into nothing. The entries are kept
|
||||
// here so the port stays legible against Slint.
|
||||
"id": "myqbz",
|
||||
"icon": "qbz-symbolic",
|
||||
// Slint: MyQbzBrandingState.label (user-editable, defaults to
|
||||
// "My QBZ"); this port has no branding state yet.
|
||||
"label": nav.trs("My QBZ"),
|
||||
"qobuz": false,
|
||||
"enabled": false,
|
||||
"entries": [
|
||||
{ "label": nav.trs("Collections"), "icon": "library-big", "view": "", "tab": "", "enabled": false },
|
||||
{ "label": nav.trs("Mixtapes"), "icon": "cassette-tape", "view": "", "tab": "", "enabled": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Which section owns the highlight, derived from the LIVE view (both hosts
|
||||
// used to carry this identical binding). Slint highlights off
|
||||
// HeaderMenuState.open-index; the hosts OR that in via `openId`.
|
||||
readonly property string activeSection:
|
||||
QbzShell.currentView === "home" ? "discover"
|
||||
: QbzShell.currentView === "library" ? "library"
|
||||
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
|
||||
: ""
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Open / close (HeaderMenuState)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Section id whose menu is open ("" = none) — the port's open-index.
|
||||
// Keyed on `visible` (flips the instant open()/close() runs), not on
|
||||
// `opened` (which waits out an enter transition).
|
||||
readonly property string openId: panel.visible ? panel.sectionId : ""
|
||||
// True while the pointer rests on the TRIGGER. The trigger owns this flag
|
||||
// (HeaderMenuOverlay.slint:51): only the trigger that owns the open menu
|
||||
// clears it, which avoids a leave/enter race when sliding between triggers.
|
||||
property bool triggerHovered: false
|
||||
|
||||
// Header dropdowns hang at a fixed window y (Slint: 45px = the 42px header
|
||||
// plus 3), NOT under each tab — the host mounts this at its own origin, so
|
||||
// in HeaderBar (top of the window) the two coincide.
|
||||
property real dropdownY: 45
|
||||
|
||||
// Header form: menu under the trigger, left edges aligned.
|
||||
// `withTitle` = the section-name header row + hairline, which Slint shows
|
||||
// for the compact icon buttons (the icon alone does not name the section)
|
||||
// and omits for the full text tabs.
|
||||
function openUnder(trigger, section, withTitle) {
|
||||
if (!section || section.enabled === false) return
|
||||
panel.show(section, withTitle === true, trigger.mapToItem(nav, 0, 0).x, nav.dropdownY)
|
||||
}
|
||||
|
||||
// Sidebar form: menu to the RIGHT of the row (+6px), aligned to its top,
|
||||
// always headed by the section name (Sidebar.slint:590).
|
||||
function openBeside(trigger, section) {
|
||||
if (!section || section.enabled === false) return
|
||||
var p = trigger.mapToItem(nav, trigger.width + 6, 0)
|
||||
panel.show(section, true, p.x, p.y)
|
||||
}
|
||||
|
||||
function closeMenu() { panel.close() }
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Entry activation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// The tab a just-navigated view must land on, held until the view mounts.
|
||||
property string pendingTab: ""
|
||||
property string pendingView: ""
|
||||
|
||||
function activate(entry) {
|
||||
panel.close()
|
||||
if (!entry || entry.enabled === false || entry.view === "") return
|
||||
if (QbzShell.currentView === entry.view) {
|
||||
// Already mounted — no currentViewChanged will fire.
|
||||
applyTab(entry.tab)
|
||||
return
|
||||
}
|
||||
nav.pendingTab = entry.tab
|
||||
nav.pendingView = entry.view
|
||||
QbzShell.navigateTo(entry.view)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onCurrentViewChanged() {
|
||||
if (nav.pendingTab === "") return
|
||||
var wanted = nav.pendingView
|
||||
var tab = nav.pendingTab
|
||||
nav.pendingTab = ""
|
||||
nav.pendingView = ""
|
||||
// A navigation somewhere else (back/forward, a card click) beat us
|
||||
// to it — drop the request instead of stamping a stale tab later.
|
||||
if (QbzShell.currentView !== wanted) return
|
||||
// The content Loader instantiates the view from its own binding on
|
||||
// the same property; binding order between it and this handler is
|
||||
// undefined, so apply after the current pass.
|
||||
Qt.callLater(function () { nav.applyTab(tab) })
|
||||
}
|
||||
}
|
||||
|
||||
// Selects a tab INSIDE the mounted view.
|
||||
//
|
||||
// TEMPORARY BRIDGE (the permanent fix is in the handoff report): the shell
|
||||
// router only carries a view id, and the three tabbed views own their tab
|
||||
// as plain QML state (HomeView/LibraryView/LocalLibraryView `activeTab`).
|
||||
// There is no bridge property to hand a tab through yet, so the mounted
|
||||
// view is located in the scene and its `activeTab` assigned directly. The
|
||||
// search is depth-capped (the view sits 5 levels under the window content
|
||||
// item: screenLoader > AppShell > contentFrame > Loader > view), so it
|
||||
// never descends into delegate forests, and it degrades to a plain
|
||||
// section navigation if nothing is found.
|
||||
function applyTab(tab) {
|
||||
if (!tab || tab === "") return
|
||||
var top = nav
|
||||
while (top.parent) top = top.parent
|
||||
var view = findTabHost(top, 0)
|
||||
if (view) view.activeTab = tab
|
||||
}
|
||||
|
||||
function findTabHost(item, depth) {
|
||||
if (!item || depth > 7) return null
|
||||
// Only HomeView / LibraryView / LocalLibraryView expose `activeTab`,
|
||||
// and exactly one of them is mounted at a time (one content Loader).
|
||||
if (typeof item.activeTab === "string") return item
|
||||
var kids = item.children
|
||||
if (!kids) return null
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
var hit = findTabHost(kids[i], depth + 1)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Idle-close (project rule: 1s), paused while the pointer rests on the
|
||||
// menu or on its trigger. Lives HERE and not inside the Popup: the Popup
|
||||
// carries an explicit contentItem, so a non-visual default-property child
|
||||
// would be handed to that content item instead of the popup.
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: false
|
||||
running: panel.visible && !panel.menuHovered
|
||||
onTriggered: panel.close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The panel (HeaderMenuOverlay's Rectangle + NavMenuItem rows)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// One menu row — NavMenuItem.slint: 32px, hover surface-hover, 14px glyph
|
||||
// (optional), 13px text-secondary label, padding 14 left / 22 right.
|
||||
component NavMenuRow: Rectangle {
|
||||
id: row
|
||||
property var entry: null
|
||||
property int rowIndex: -1
|
||||
readonly property bool rowEnabled: entry && entry.enabled !== false
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 32
|
||||
opacity: rowEnabled ? 1.0 : 0.45
|
||||
color: (rowEnabled && rowArea.containsMouse) ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 22
|
||||
spacing: 9
|
||||
|
||||
QbzIcon {
|
||||
visible: row.entry && row.entry.icon !== ""
|
||||
width: visible ? 14 : 0
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: row.entry ? row.entry.icon : ""
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
height: parent.height
|
||||
width: parent.width - (row.entry && row.entry.icon !== "" ? 23 : 0)
|
||||
text: row.entry ? row.entry.label : ""
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
enabled: row.rowEnabled
|
||||
hoverEnabled: row.rowEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: nav.activate(row.entry)
|
||||
// Per-row hover keeps the idle timer paused while the cursor RESTS
|
||||
// on a row (the panel-body tracker below never sees it — the row
|
||||
// is on top of it).
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) panel.hoveredRow = row.rowIndex
|
||||
else if (panel.hoveredRow === row.rowIndex) panel.hoveredRow = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: panel
|
||||
width: 188
|
||||
padding: 0
|
||||
topPadding: 6
|
||||
bottomPadding: 6
|
||||
// Deterministic height (Slint: panel-box.preferred-height) — 32px per
|
||||
// row, plus the optional 26px title band and its 1px hairline.
|
||||
height: 12 + (panel.title === "" ? 0 : 27) + panel.entries.length * 32
|
||||
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
||||
|
||||
property string sectionId: ""
|
||||
property string title: ""
|
||||
property var entries: []
|
||||
// Which row the pointer is on (-1 = none); rows do not overlap.
|
||||
property int hoveredRow: -1
|
||||
// True while the pointer rests on the panel BODY (padding/gaps).
|
||||
property bool bodyHovered: false
|
||||
// Pointer anywhere on the menu, or on the trigger that owns it — the
|
||||
// idle countdown is paused while this holds.
|
||||
readonly property bool menuHovered:
|
||||
bodyHovered || hoveredRow >= 0 || nav.triggerHovered
|
||||
|
||||
function show(section, withTitle, px, py) {
|
||||
// Switching sections: drop stale hover state (the new rows have
|
||||
// not reported yet), exactly like the Slint `changed open-index`.
|
||||
if (panel.sectionId !== section.id) panel.hoveredRow = -1
|
||||
panel.sectionId = section.id
|
||||
panel.title = withTitle ? section.label : ""
|
||||
panel.entries = section.entries
|
||||
panel.x = px
|
||||
panel.y = py
|
||||
if (!panel.visible) panel.open()
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
panel.sectionId = ""
|
||||
panel.hoveredRow = -1
|
||||
panel.bodyHovered = false
|
||||
nav.triggerHovered = false
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
|
||||
// Panel-body hover tracker. It lives in the BACKGROUND, i.e. below
|
||||
// the content item, so it can never swallow a row's press (a fill
|
||||
// MouseArea declared over the rows would — a press is not a
|
||||
// composed event and propagateComposedEvents does not rescue it).
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onContainsMouseChanged: panel.bodyHovered = containsMouse
|
||||
// Swallow clicks on the panel chrome so they do not fall
|
||||
// through to whatever sits under the flyout.
|
||||
onClicked: {}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Column {
|
||||
spacing: 0
|
||||
|
||||
// Optional section-name header + hairline (Slint: shown for the
|
||||
// compact header buttons and every sidebar flyout).
|
||||
Text {
|
||||
visible: panel.title !== ""
|
||||
width: parent.width
|
||||
height: visible ? 26 : 0
|
||||
leftPadding: 14
|
||||
rightPadding: 14
|
||||
text: panel.title
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Rectangle {
|
||||
visible: panel.title !== ""
|
||||
width: parent.width
|
||||
height: visible ? 1 : 0
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: panel.entries
|
||||
delegate: NavMenuRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
entry: modelData
|
||||
rowIndex: index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,11 +183,18 @@ Rectangle {
|
||||
radius: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
// Buffered / cache line — SeekBar.slint:58 paints it
|
||||
// text-muted @0.35, NOT border-muted: border-muted is an
|
||||
// alpha-over-surface token (white-based on dark themes,
|
||||
// black-based on light), so it inverted against the
|
||||
// surface-elevated rail on light themes. The Small bar
|
||||
// already used the right pair.
|
||||
Rectangle {
|
||||
width: parent.width * Math.min(Math.max(QbzPlayer.npCacheProgress, 0), 1)
|
||||
height: parent.height
|
||||
radius: 2
|
||||
color: theme.borderMuted
|
||||
color: theme.textMuted
|
||||
opacity: 0.35
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width * Math.min(Math.max(QbzPlayer.npProgress, 0), 1)
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
// sections move to the header (HeaderBar.qml) and this sidebar starts at the
|
||||
// PLAYLISTS toolbar, exactly like Sidebar.slint:724/817.
|
||||
//
|
||||
// POC-NOTE: in the Slint app these rows open dropdown flyout menus; the
|
||||
// flyouts are out of scope — rows here navigate straight to their view and
|
||||
// carry the SidebarDirectRow active treatment (surface-hover bg + primary
|
||||
// text/icon) for the current section. Discover / Library / Local Library
|
||||
// have views; My QBZ does NOT and is therefore rendered disabled (a row
|
||||
// that clicks into nothing is a defect, not a stub).
|
||||
// The rows open the same dropdown FLYOUT their Slint counterparts do
|
||||
// (SidebarNavRow: hover or click, panel to the RIGHT of the row, headed by
|
||||
// the section name) — the panel, its behaviour and the entry catalog live in
|
||||
// the shared NavFlyout.qml, which HeaderBar.qml mounts too, so the sections
|
||||
// behave identically wherever the nav is hosted. The rows keep the
|
||||
// SidebarDirectRow active treatment (surface-hover bg + primary text/icon)
|
||||
// for the current section and while their own menu is open.
|
||||
// Discover / Library / Local Library route into real views; My QBZ does NOT
|
||||
// (no such surface in this port) and is therefore rendered disabled, flyout
|
||||
// included (a row that clicks into nothing is a defect, not a stub).
|
||||
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
|
||||
// sort, search, expand/collapse, drag-drop target); folder CREATION,
|
||||
// the Playlist Manager and the importer have no seam and are disabled in
|
||||
@@ -32,17 +36,12 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property bool mini: QbzShell.sidebarState === 1
|
||||
// Which section owns the highlight. DERIVED from the live view, not set on
|
||||
// click: the same rows can live here or in the header, history navigation
|
||||
// (back/forward) must keep the highlight honest, and a click-set local
|
||||
// went stale on both counts. Slint highlights its rows off
|
||||
// HeaderMenuState.open-index — a flyout this port does not have — so the
|
||||
// current view is the faithful stand-in (it is what SidebarDirectRow uses).
|
||||
readonly property string activeNav:
|
||||
QbzShell.currentView === "home" ? "discover"
|
||||
: QbzShell.currentView === "library" ? "library"
|
||||
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
|
||||
: ""
|
||||
// Which section owns the highlight. DERIVED from the live view (in
|
||||
// NavFlyout, shared with the header), not set on click: the same rows can
|
||||
// live here or in the header, and history navigation (back/forward) must
|
||||
// keep the highlight honest. Slint additionally highlights the row whose
|
||||
// menu is open — the rows OR `navFlyout.openId` in for that.
|
||||
readonly property string activeNav: navFlyout.activeSection
|
||||
// Playlist tree state (phase 7).
|
||||
property bool searchOpen: false
|
||||
property bool playlistsCollapsed: false
|
||||
@@ -87,6 +86,13 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// The section-nav dropdown (catalog + panel + open/close behaviour) —
|
||||
// the SAME component HeaderBar.qml mounts. Zero-size and outside the
|
||||
// Column so its origin stays this root's origin (the row coordinates are
|
||||
// mapped into it). The panel renders in the window overlay, so neither
|
||||
// this root's `clip` nor its 240px width can cut it off.
|
||||
NavFlyout { id: navFlyout }
|
||||
|
||||
width: QbzShell.sidebarState === 2 ? 0
|
||||
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
|
||||
: theme.sidebarOpenWidth
|
||||
@@ -102,23 +108,24 @@ Rectangle {
|
||||
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
|
||||
// One section-nav row (SidebarNavRow / SidebarDirectRow metrics).
|
||||
// One section-nav row (SidebarNavRow / SidebarDirectRow metrics). Hovering
|
||||
// or clicking it opens that section's flyout to the RIGHT of the row.
|
||||
component NavRow: Rectangle {
|
||||
id: navRow
|
||||
property string navId: ""
|
||||
property string name: ""
|
||||
property string label: ""
|
||||
property bool active: root.activeNav === navId
|
||||
// No view behind this section in this port — render it as
|
||||
// A section from the shared catalog (NavFlyout.sections).
|
||||
property var section: null
|
||||
readonly property bool isActive: section
|
||||
&& (root.activeNav === section.id || navFlyout.openId === section.id)
|
||||
// No surface behind this section in this port — rendered as
|
||||
// unavailable rather than as a live row that clicks into nothing.
|
||||
property bool disabled: false
|
||||
signal clicked()
|
||||
readonly property bool isEnabled: section && section.enabled
|
||||
readonly property bool lit: isActive || (navArea.containsMouse && isEnabled)
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 34
|
||||
radius: 6
|
||||
opacity: disabled ? 0.45 : 1.0
|
||||
color: (!disabled && (navArea.containsMouse || active)) ? theme.surfaceHover : "transparent"
|
||||
opacity: isEnabled ? 1.0 : 0.45
|
||||
color: lit ? theme.surfaceHover : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
@@ -130,21 +137,19 @@ Rectangle {
|
||||
width: root.mini ? parent.width : 16
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
name: navRow.name
|
||||
name: navRow.section ? navRow.section.icon : ""
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: (navArea.containsMouse || navRow.active)
|
||||
? "primary" : "secondary"
|
||||
tintName: navRow.lit ? "primary" : "secondary"
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: !root.mini
|
||||
height: parent.height
|
||||
width: parent.width - (root.mini ? 0 : 26)
|
||||
text: navRow.label
|
||||
color: (navArea.containsMouse || navRow.active)
|
||||
? theme.textPrimary : theme.textSecondary
|
||||
text: navRow.section ? navRow.section.label : ""
|
||||
color: navRow.lit ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -154,10 +159,21 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: navArea
|
||||
anchors.fill: parent
|
||||
enabled: !navRow.disabled
|
||||
hoverEnabled: !navRow.disabled
|
||||
enabled: navRow.isEnabled
|
||||
hoverEnabled: navRow.isEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: navRow.clicked()
|
||||
onClicked: navFlyout.openBeside(navRow, navRow.section)
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) {
|
||||
// Hover-to-open; hovering another row overwrites the open
|
||||
// menu (instant switch, single-open by construction).
|
||||
navFlyout.triggerHovered = true
|
||||
navFlyout.openBeside(navRow, navRow.section)
|
||||
} else if (navRow.section && navFlyout.openId === navRow.section.id) {
|
||||
// Only the row owning the open menu clears the flag.
|
||||
navFlyout.triggerHovered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,37 +198,18 @@ Rectangle {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
|
||||
// Qobuz-only sections — HIDDEN entirely while offline (ADR-010).
|
||||
NavRow {
|
||||
navId: "discover"
|
||||
name: "compass"
|
||||
label: QbzSession.tr("Discover", QbzSession.trRev)
|
||||
visible: !QbzSession.offline
|
||||
onClicked: QbzShell.navigateTo("home")
|
||||
}
|
||||
NavRow {
|
||||
navId: "library"
|
||||
name: "music-library-2"
|
||||
label: QbzSession.tr("Library", QbzSession.trRev)
|
||||
visible: !QbzSession.offline
|
||||
onClicked: QbzShell.navigateTo("library")
|
||||
}
|
||||
NavRow {
|
||||
navId: "local"
|
||||
name: "hard-drive"
|
||||
label: QbzSession.tr("Local Library", QbzSession.trRev)
|
||||
onClicked: QbzShell.navigateTo("local")
|
||||
}
|
||||
// GAP: there is no MyQBZ view in this port (AppShell's loader
|
||||
// maps home/library/local/localalbum/album/artist/settings/
|
||||
// search/playlist — no "myqbz"). It used to take the active
|
||||
// treatment on click and go nowhere; it now renders disabled.
|
||||
NavRow {
|
||||
navId: "myqbz"
|
||||
name: "qbz-symbolic"
|
||||
// Slint: MyQbzBrandingState.label, default "My QBZ".
|
||||
label: QbzSession.tr("My QBZ", QbzSession.trRev)
|
||||
disabled: true
|
||||
// The same four sections the header hosts, from the same catalog.
|
||||
// Qobuz-only ones are HIDDEN entirely while offline (ADR-010
|
||||
// mount-site gating, not rendered-disabled); My QBZ carries
|
||||
// enabled: false in the catalog (no surface behind it in this
|
||||
// port) and so renders dimmed and inert, flyout included.
|
||||
Repeater {
|
||||
model: navFlyout.sections
|
||||
delegate: NavRow {
|
||||
required property var modelData
|
||||
section: modelData
|
||||
visible: !(modelData.qobuz && QbzSession.offline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Bake matching the LIVE Theme.text-primary (SongCard.slint:504 tints the
|
||||
// context-stack glyph text-primary on hover, text-muted idle). A
|
||||
// hardcoded bake name is a dark-theme-only colour — see QbzIconButton's
|
||||
// header. text-muted keeps the "muted" bake: #888888 reads on both
|
||||
// polarities.
|
||||
readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
|
||||
|
||||
width: 200
|
||||
height: glass ? 64 : 74
|
||||
radius: glass ? 8 : 0
|
||||
@@ -78,7 +85,13 @@ Rectangle {
|
||||
anchors.centerIn: parent
|
||||
tintName: "muted"
|
||||
}
|
||||
// Fetch overlay (resolving/downloading).
|
||||
// Fetch overlay (resolving/downloading). DELIBERATE literal,
|
||||
// not a theme token: this scrim lies over ALBUM ARTWORK, not
|
||||
// over a themed surface, so it must darken in BOTH polarities
|
||||
// — theme.alphaTier() is white-based on dark themes and would
|
||||
// wash the cover out instead of dimming it, and cardShadow is
|
||||
// a per-theme shadow colour, not a scrim. 1:1 with
|
||||
// SongCard.slint:278 (`background: #000000aa`).
|
||||
Rectangle {
|
||||
visible: QbzPlayer.npHasTrack && QbzPlayer.npLoading
|
||||
anchors.fill: parent
|
||||
@@ -133,7 +146,7 @@ Rectangle {
|
||||
width: 13
|
||||
height: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: ctxArea.containsMouse ? "primary" : "muted"
|
||||
tintName: ctxArea.containsMouse ? root.tintStrong : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: ctxArea
|
||||
|
||||
@@ -32,6 +32,15 @@ Row {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Bakes matching the LIVE tokens (see QbzIconButton's header: the icon
|
||||
// tints are frozen SVG colours, so a hardcoded name is a dark-theme-only
|
||||
// colour). `tintOnAccent` is the glyph sitting ON the filled play disc:
|
||||
// the disc is Theme.accent, so its glyph is Theme.accent-text — which is
|
||||
// near-white on some themes and near-black on others (qbz-theme picks it
|
||||
// for worst-case contrast against the accent), never a fixed "black".
|
||||
readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
|
||||
readonly property string tintOnAccent: theme.accentText.hslLightness > 0.5 ? "primary" : "black"
|
||||
|
||||
spacing: 2
|
||||
height: 44
|
||||
|
||||
@@ -70,8 +79,8 @@ Row {
|
||||
width: 20
|
||||
height: 20
|
||||
anchors.centerIn: parent
|
||||
tintName: tc.playCircle ? "black"
|
||||
: (playArea.containsMouse ? "accent" : "primary")
|
||||
tintName: tc.playCircle ? tc.tintOnAccent
|
||||
: (playArea.containsMouse ? "accent" : tc.tintStrong)
|
||||
}
|
||||
MouseArea {
|
||||
id: playArea
|
||||
|
||||
Reference in new issue
Block a user