Both inline fields were bare TextInputs: no clear cross, no Escape. They are QbzSearchField now — Escape empties the filter, hands the keyboard back and folds the field into its title; the cross empties it and keeps the caret. The control grew what a 22px header row needs: an optional leading glyph (the toggle already shows one), a cross sized to the field, and an `escaped` signal for hosts that fold the field away. cc
2085 lines
110 KiB
QML
2085 lines
110 KiB
QML
// Left navigation sidebar — QML port of crates/qbz-ui/ui/shell/Sidebar.slint.
|
||
//
|
||
// Three states (ShellState.sidebar-state): 0 = open 240px (icon + label),
|
||
// 1 = mini (icons only), 2 = closed 0px. Width animates 160ms ease-in-out;
|
||
// the header's panel-left button cycles (QbzShell.cycleSidebar).
|
||
//
|
||
// MINI RAIL: 64px, 1:1 with Slint (state.slint:4075), holding a 34px row
|
||
// (Sidebar.slint:575/652) on symmetric 15px padding. It was 50px for a while —
|
||
// an owner deviation that turned out to put the rail's centre 5px left of the
|
||
// header control sitting directly above it; see `miniPadLeft` below and
|
||
// QbzTheme.sidebarMiniWidth for the measurement. Everything else
|
||
// in the mini state is 1:1 with Slint: rows centre their lone glyph, nested
|
||
// playlist rows collapse to zero, hovering a row names it in a bubble to the
|
||
// right (SidebarTooltip.slint) and clicking a FOLDER opens its playlists in a
|
||
// flyout to the right (SidebarFolderPopup.slint) — in mini a folder cannot
|
||
// expand in place, so a toggle there would be a control that does nothing.
|
||
//
|
||
// FILE LENGTH (>500): what remains after the mini folder flyout moved to
|
||
// shell/SidebarFolderFlyout.qml and the row context menu to
|
||
// shell/SidebarRowMenu.qml is ONE surface with one state machine — the three
|
||
// sidebar widths. Every block below is a WIDTH-CONDITIONAL variant of the same
|
||
// tree (the nav rows, the PLAYLISTS toolbar, the row delegate and the
|
||
// scrollbar each read `root.mini`), so splitting them into files would hand
|
||
// each half a `mini` property to keep in sync and put the row delegate's
|
||
// drop-target arithmetic — which needs `root`'s own window coordinates — a
|
||
// file away from the root it measures. The three things that DID come out are
|
||
// the three that are self-contained: both overlays and the hover bubble (the
|
||
// shell's shared QbzTooltip). All three are shared single instances declared
|
||
// at this root, never one per row: the tree can hold hundreds of rows.
|
||
//
|
||
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
|
||
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
|
||
// labels, surface-hover on hover, Discover + Library HIDDEN while offline
|
||
// (ADR-010 mount-site gating). The whole block — rows AND the hairline under
|
||
// them — is mounted only while `QbzShell.navInSidebar` is ON; with it OFF the
|
||
// sections move to the header (HeaderBar.qml) and this sidebar starts at the
|
||
// PLAYLISTS toolbar, exactly like Sidebar.slint:724/817.
|
||
//
|
||
// 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.
|
||
// All four sections route into real views, My QBZ included (its Collections /
|
||
// Mixtapes entries reach the "collections" / "mixtapes" routes). The disabled
|
||
// treatment the NavRow still carries — dimmed to 0.45, pointer-inert, no
|
||
// flyout — is the mechanism for a section with no surface behind it, and
|
||
// nothing uses it today (a row that clicks into nothing is a defect, not a
|
||
// stub). Its leading glyph goes through shell/NavSectionGlyph.qml, because My
|
||
// QBZ can carry a user-supplied icon (SidebarNavRow `raw-icon`).
|
||
// PURCHASES is the one row in that block that is NOT a section: it is a
|
||
// DirectRow (SidebarDirectRow) — no dropdown, no catalog entry, it just
|
||
// navigates — and it is triple-gated (opt-in `show_purchases`, hidden while
|
||
// offline, and shown only while the sections themselves live here: with the
|
||
// navigation in the header it follows them up there). The pref defaults
|
||
// OFF, so on a stock install this block is still the same four section rows
|
||
// it has always been.
|
||
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load, sort,
|
||
// search, expand/collapse, drag-drop target), and so is everything the "..."
|
||
// menu offers: New folder opens QbzFolderEdit's create panel (the small
|
||
// name-only create modal, controls/FolderModals.qml), Manage playlists routes
|
||
// to the Playlist Manager, Import opens the importer and Refresh rebuilds the
|
||
// tree. Import is the only row that dims — it is the only Qobuz-only one, so it
|
||
// follows the reference's `enabled: !offline`; Refresh stays lit because its
|
||
// bridge call is the offline-safe rebuild (see the Repeater's comment). Nothing
|
||
// in this file renders a control that no-ops.
|
||
//
|
||
// Right-pressing a row opens shell/SidebarRowMenu.qml (edit / hide / mixtape /
|
||
// move-to-folder). It is suppressed on the mini rail: Slint anchors that menu
|
||
// at `row.width - 210px`, which on a 34px rail row is deeply negative and Qt's
|
||
// viewport clamp would drop it at x=8 — not the reference's placement, just
|
||
// the clamp.
|
||
|
||
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Effects
|
||
import com.blitzfc.qbz
|
||
import "../controls"
|
||
import "../theme"
|
||
|
||
Rectangle {
|
||
id: root
|
||
|
||
property bool mini: QbzShell.sidebarState === 1
|
||
|
||
// Purchases downloading now — `QbzPurchases.activeDownloadsJson`, parsed
|
||
// once per publish (guarded: "[]" on the pre-boot frame).
|
||
readonly property var activeDownloads: {
|
||
try { return JSON.parse(QbzPurchases.activeDownloadsJson || "[]") } catch (e) { return [] }
|
||
}
|
||
/// Mean completion across the active albums, 0..1, for the mini ring.
|
||
readonly property real activeDownloadsOverall: {
|
||
var rows = root.activeDownloads
|
||
if (rows.length === 0) return 0
|
||
var sum = 0
|
||
for (var i = 0; i < rows.length; i++) sum += (rows[i].percent || 0)
|
||
return sum / rows.length / 100
|
||
}
|
||
/// The hover bubble's text: one line per album, "Title — 7/14 · 50%".
|
||
readonly property string activeDownloadsSummary: {
|
||
var rows = root.activeDownloads
|
||
var lines = []
|
||
for (var i = 0; i < rows.length; i++)
|
||
lines.push((rows[i].title || QbzSession.tr("Purchases", QbzSession.trRev))
|
||
+ " — " + (rows[i].done || 0) + "/" + (rows[i].total || 0)
|
||
+ " · " + (rows[i].percent || 0) + "%")
|
||
return lines.join("\n")
|
||
}
|
||
// The shell's ONE hover-tooltip overlay (controls/QbzTooltip.qml), fed in
|
||
// by AppShell. Null when this component is mounted in isolation, in which
|
||
// case the rail simply shows no bubble.
|
||
property Item tooltip: null
|
||
|
||
// ---- Collapsed-rail optical centring ---------------------------------
|
||
// MEASURED, not nudged (release binary, 1600x1000, xcb, pixels sampled off
|
||
// the framebuffer). The finding that mattered: the rail's content was
|
||
// ALREADY symmetric on the sidebar's own geometry — hover pill centre 25.0
|
||
// against a rail centre of 25.0 — and the owner still read it as off. The
|
||
// eye was not comparing the glyphs to the rail. It was comparing them to
|
||
// the header's leading control, the panel-left sidebar toggle, which sits
|
||
// at x = spacingMd(16) and is 28px wide (HeaderBar.qml:225 +
|
||
// QbzNavButton.qml:102) -> centre 30, directly above them. Five pixels
|
||
// apart in one vertical line is visible; symmetric padding does not help.
|
||
//
|
||
// Nor is there a visible rail edge to centre against: the sidebar is
|
||
// surface-card and the content frame's 8px left gutter is surface-card too
|
||
// (AppShell.qml:149 paints the panel; the frame around it is the shell
|
||
// base), so it reads as one uninterrupted band, sampled (26,26,26) from
|
||
// x=1 to x=57 and (15,15,15) from 58 — band centre 29.
|
||
//
|
||
// The rail is back at Slint's 64px, which dissolves the conflict instead
|
||
// of trading one misalignment for another: row centre 32 vs header button
|
||
// 30. See QbzTheme.sidebarMiniWidth for why 50 was the actual defect.
|
||
// Padding is symmetric and DERIVED, so the pair can never drift apart.
|
||
readonly property int miniPadLeft: (theme.sidebarMiniWidth - theme.sidebarMiniRow) / 2
|
||
readonly property int miniPadRight: root.miniPadLeft
|
||
// 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
|
||
|
||
// ---- Purchases row gating (contract §7.1, Sidebar.slint:794-805) -----
|
||
// NO new bridge property is needed. `settings_bool`'s tail is
|
||
// unconditional on success — `publish_snapshot()` re-serialises the WHOLE
|
||
// settings document after every successful write (settings_qt.rs:2278-2285)
|
||
// — and the pref is already in it (`showPurchases`). So the Navigation
|
||
// toggle reaches this row through QbzBridge.settingsJson and nothing
|
||
// else is wired.
|
||
//
|
||
// GUARDED parse, the NavFlyout.qml:65-70 precedent: a bare JSON.parse in a
|
||
// binding throws on the pre-publish frame and would take the whole sidebar
|
||
// down with it, not just this row.
|
||
readonly property var settingsDoc: {
|
||
try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) }
|
||
}
|
||
// `show_purchases` is the MASTER gate. Purchases then FOLLOWS the other
|
||
// sections (2026-09-13): it sits here while the navigation lives in the
|
||
// sidebar, and in the header beside them otherwise (HeaderBar.qml
|
||
// `purchasesInHeader`) — never the one entry left behind. The pref
|
||
// defaults false: Purchases ships hidden.
|
||
readonly property bool purchasesVisible:
|
||
root.settingsDoc.showPurchases === true
|
||
&& !QbzSession.offline
|
||
&& QbzShell.navInSidebar
|
||
|
||
// Playlist tree state (phase 7).
|
||
property bool searchOpen: false
|
||
// PERSISTED collapse state (owner request 2026-09-10): read live from the
|
||
// republished settingsJson, toggled through `settingsBool`, so a collapsed
|
||
// section stays collapsed across restarts. Default expanded.
|
||
readonly property bool playlistsCollapsed: settingsDoc.sidebarPlaylistsCollapsed === true
|
||
property string activePlaylistId: ""
|
||
|
||
// ---- Collapsible My QBZ tree (opt-in) -------------------------------
|
||
// Collapse state persisted the same way as the playlist tree above. The
|
||
// parent "My QBZ" header collapses the whole block; each section collapses
|
||
// its own list.
|
||
readonly property bool myqbzCollapsed: settingsDoc.myqbzCollapsed === true
|
||
readonly property bool myqbzMixtapesCollapsed: settingsDoc.myqbzMixtapesCollapsed === true
|
||
readonly property bool myqbzCollectionsCollapsed: settingsDoc.myqbzCollectionsCollapsed === true
|
||
// The light tree feed (myqbz_qt::publish_sidebar_tree): both grids' rows as
|
||
// {mixtapes:[{id,name,hidden}], collections:[…]}. GUARDED parse, same
|
||
// precedent as `settingsDoc`. Hidden rows are dropped here so the sidebar
|
||
// honours the per-element "Hide from sidebar" flag.
|
||
// EXPLICIT refresh, not a declarative binding: a plain `var` block binding
|
||
// on QbzShell.myqbzTreeJson did not re-evaluate on republish here (the tree
|
||
// stayed empty though Rust logged the feed). The grids drive their model the
|
||
// same way — `onItemsChanged` + `Component.onCompleted` (MyQbzGridView.qml:
|
||
// 152-153) — so mirror that: a Connections handler re-parses on every
|
||
// change, and `Component.onCompleted` seeds whatever is already published.
|
||
// PLAIN arrays assigned together in refreshMyqbzTree — NOT derived
|
||
// bindings. A derived `var` binding on another `var` (myqbzMixtapes:
|
||
// _visibleRows(myqbzTree.mixtapes)) did not re-evaluate when the source was
|
||
// assigned imperatively (the feed arrived — proven in the log — yet the
|
||
// rows stayed empty). Assigning the final arrays directly makes the
|
||
// Repeater/empty-state bindings react the ordinary way.
|
||
property var myqbzMixtapes: []
|
||
property var myqbzCollections: []
|
||
// Raw feed rows (hidden dropped), kept so a search / sort change re-derives
|
||
// the visible arrays without a DB round trip.
|
||
property var _myqbzMixRaw: []
|
||
property var _myqbzColRaw: []
|
||
// Playlists-parity toolbar state: inline search over the tree, and a simple
|
||
// A–Z / Z–A name sort.
|
||
property bool myqbzSearchOpen: false
|
||
property string myqbzSearch: ""
|
||
property bool myqbzSortAsc: true
|
||
function _visibleRows(arr) {
|
||
var out = []
|
||
if (!arr) return out
|
||
for (var i = 0; i < arr.length; ++i)
|
||
if (arr[i] && arr[i].hidden !== true) out.push(arr[i])
|
||
return out
|
||
}
|
||
// Apply the live search filter + name sort to one raw list.
|
||
function _filterSortMyqbz(arr) {
|
||
var q = root.myqbzSearch.trim().toLowerCase()
|
||
var out = []
|
||
for (var i = 0; i < arr.length; ++i) {
|
||
var r = arr[i]
|
||
if (!r) continue
|
||
if (q !== "" && String(r.name).toLowerCase().indexOf(q) === -1) continue
|
||
out.push(r)
|
||
}
|
||
out.sort(function (a, b) {
|
||
var an = String(a.name).toLowerCase()
|
||
var bn = String(b.name).toLowerCase()
|
||
if (an === bn) return 0
|
||
var lt = an < bn
|
||
return root.myqbzSortAsc ? (lt ? -1 : 1) : (lt ? 1 : -1)
|
||
})
|
||
return out
|
||
}
|
||
function _applyMyqbz() {
|
||
root.myqbzMixtapes = _filterSortMyqbz(root._myqbzMixRaw)
|
||
root.myqbzCollections = _filterSortMyqbz(root._myqbzColRaw)
|
||
}
|
||
function refreshMyqbzTree() {
|
||
var t
|
||
try { t = JSON.parse(QbzShell.myqbzTreeJson) }
|
||
catch (e) { t = ({ "mixtapes": [], "collections": [] }) }
|
||
root._myqbzMixRaw = _visibleRows(t.mixtapes)
|
||
root._myqbzColRaw = _visibleRows(t.collections)
|
||
root._applyMyqbz()
|
||
}
|
||
onMyqbzSearchChanged: root._applyMyqbz()
|
||
onMyqbzSortAscChanged: root._applyMyqbz()
|
||
Connections {
|
||
target: QbzShell
|
||
function onMyqbzTreeJsonChanged() { root.refreshMyqbzTree() }
|
||
}
|
||
Component.onCompleted: root.refreshMyqbzTree()
|
||
// Master gate + the two section gates + the standard-entry suppressor.
|
||
readonly property bool myqbzTreeOn: settingsDoc.collapsibleMyqbz === true
|
||
readonly property bool myqbzShowMixtapes: settingsDoc.myqbzShowMixtapes !== false
|
||
readonly property bool myqbzShowCollections: settingsDoc.myqbzShowCollections !== false
|
||
// The collapsible tree REPLACES the flat My QBZ nav entry — its parent
|
||
// header click reaches the same surface — so the two are mutually
|
||
// exclusive: whenever the tree is on, the classic row is suppressed. (No
|
||
// separate toggle; keeping both would be the same entry twice.)
|
||
readonly property bool hideStandardMyqbz: myqbzTreeOn
|
||
// ---- Large-NPB dock space reservation -------------------------------
|
||
// The cover+spectrum dock is an AppShell-ROOT overlay pinned flush to the
|
||
// WINDOW bottom-left, so it extends the bar's height BELOW this sidebar
|
||
// (which stops at npb.top). Only the IN-SIDEBAR portion has to be
|
||
// reserved, and it shrinks when the band is hidden — that is why the
|
||
// height comes from the bridge and not a literal.
|
||
//
|
||
// Without this the playlist list keeps running to the sidebar's bottom
|
||
// edge and the rows sit UNDER the album art (Sidebar.slint:1145 reserves
|
||
// the same way).
|
||
readonly property bool largeDockActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
|
||
readonly property real dockReserve: largeDockActive
|
||
? Math.max(0, QbzShell.largeDockHeight - theme.npbLargeHeight)
|
||
: 0
|
||
|
||
// Flattened entries from the bridge + the url-keyed cover map.
|
||
readonly property var entries: parseEntries(QbzShell.sidebarJson)
|
||
// A republish rebuilds the Repeater's delegates, so the row a bubble is
|
||
// anchored to may be gone or moved and its MouseArea will never report the
|
||
// leave. The overlay outlives the delegate by design (it stores numbers,
|
||
// never the Item), so nothing dangles — but it would linger, so drop it.
|
||
onEntriesChanged: if (root.tooltip) root.tooltip.hideAll()
|
||
property var coverMap: ({})
|
||
function parseEntries(json) {
|
||
var e = JSON.parse(json)
|
||
// Collect every cover url and dispatch the artwork window.
|
||
var urls = []
|
||
for (var i = 0; i < e.length; i++) {
|
||
for (var j = 0; j < e[i].covers.length; j++) urls.push(e[i].covers[j])
|
||
}
|
||
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
|
||
return e
|
||
}
|
||
|
||
Connections {
|
||
target: QbzLibrary
|
||
function onLibraryArtworkReady(key, path) {
|
||
var m = root.coverMap
|
||
m[key] = path
|
||
root.coverMap = Object.assign({}, m)
|
||
}
|
||
}
|
||
|
||
/// Shader availability for the playlist micro-collage's rounding mask.
|
||
/// Verbatim from cards/PlaylistCollage.qml:120-122 (and
|
||
/// theme/RoundedImage.qml:234-236): the ONLY case the retired "effects
|
||
/// render nothing" doctrine was ever true for is the software/Null
|
||
/// renderer, and `GraphicsInfo.api` answers it per window at runtime
|
||
/// (`api` carries `notify: "apiChanged"`, so this re-evaluates when the
|
||
/// window arrives). Test Software/Null NEGATIVELY — under the RHI an
|
||
/// OpenGL backend reports `GraphicsInfo.OpenGL`. `QbzShell.forceCanvasArt`
|
||
/// (env `QBZ_QT_ROUND_MODE=canvas`, default OFF) is honoured here too, so
|
||
/// a machine where `GraphicsInfo` reports a GPU and the mask still
|
||
/// misbehaves degrades to square corners instead of losing the covers.
|
||
/// `typeof` guard mirrors both files: eager binding, and the sidebar must
|
||
/// stay loadable in an isolated qml6 scene.
|
||
///
|
||
/// Declared ONCE on this root rather than per delegate: the answer is a
|
||
/// per-window constant and the playlist tree can hold hundreds of rows.
|
||
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|
||
|| GraphicsInfo.api === GraphicsInfo.Null
|
||
|| (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt)
|
||
|
||
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 }
|
||
|
||
// ---- Collapsed (mini) affordances -----------------------------------
|
||
// In the mini rail a playlist row is an icon and nothing else, so the two
|
||
// things the open sidebar gives for free have to be restored:
|
||
// - the NAME, as a hover bubble to the row's right (SidebarTooltip.slint);
|
||
// - a folder's CONTENTS, as a flyout to the row's right, because the
|
||
// nested rows are hidden in mini (SidebarFolderPopup.slint).
|
||
// Both are ONE shared instance driven by the hovered/clicked row (exactly
|
||
// like Slint's SidebarTooltipState / SidebarFolderPopupState), never one
|
||
// per delegate: the tree can hold hundreds of rows.
|
||
// Neither can live inside this root's bounds, because it clips. The FLYOUT
|
||
// is a QtQuick.Controls Popup, so its content is reparented to the WINDOW
|
||
// overlay — the same reason NavFlyout and HeaderBar's playlists flyout are
|
||
// Popups. The BUBBLE is not a popup at all: it is the shell-level
|
||
// QbzTooltip overlay (`root.tooltip`, fed in by AppShell), for the reasons
|
||
// spelled out where the old ToolTip used to be declared, below.
|
||
|
||
// Race-safe show/hide (Slint SidebarRow.show-tooltip / hide-tooltip): the
|
||
// leaving row only clears the bubble if it still owns it, so sliding the
|
||
// pointer straight from one row to the next never blanks it. Both halves
|
||
// now live in the shared overlay, keyed by the entry id.
|
||
function showMiniTip(row, entry) {
|
||
if (!root.mini || !root.tooltip)
|
||
return
|
||
root.tooltip.showRight(row, entry.id, entry.kind === "folder"
|
||
? entry.name + " (" + entry.count + ")"
|
||
: entry.name)
|
||
}
|
||
function hideMiniTip(id) {
|
||
if (root.tooltip)
|
||
root.tooltip.hide(id)
|
||
}
|
||
|
||
// Mini folder click: the flyout, not a toggle (the nested rows are hidden,
|
||
// so toggling is a control that renders and does nothing — Slint opens the
|
||
// popup instead, Sidebar.slint:355-377).
|
||
//
|
||
// It no longer force-expands the folder on the way in. That used to be
|
||
// necessary because the flyout filtered the published `entries`, which
|
||
// carry a folder's children ONLY while it is expanded — a persistent side
|
||
// effect (the folder stayed open once the sidebar was re-opened) the
|
||
// reference does not have. The flyout now asks Rust for a dedicated
|
||
// cache-built document instead (SidebarFolderFlyout.qml / §4.7).
|
||
function openFolderFlyout(row, entry) {
|
||
// The flyout takes the same +6px slot the bubble occupies, and it is a
|
||
// Popup (window overlay) so it would paint straight over it. Slint drops
|
||
// the tooltip explicitly here too (Sidebar.slint:353).
|
||
if (root.tooltip) root.tooltip.hideAll()
|
||
folderFlyout.openFor(entry, row)
|
||
}
|
||
|
||
// Cycling the sidebar while any of the three is open would leave it
|
||
// floating over a rail that no longer exists (the bubble self-hides
|
||
// through `root.mini`; the two Popups need telling).
|
||
Connections {
|
||
target: QbzShell
|
||
function onSidebarStateChanged() {
|
||
folderFlyout.close()
|
||
// The row menu is row-anchored and the rows are about to change
|
||
// width — or, going to state 2, stop existing.
|
||
rowMenu.close()
|
||
// The bubble's anchor is a rail row that is about to move or stop
|
||
// existing (SidebarTooltip.slint:14 gates on sidebar-mini for the
|
||
// same reason).
|
||
if (root.tooltip) root.tooltip.hideAll()
|
||
}
|
||
}
|
||
|
||
// The hover bubble that names a collapsed row is NOT declared here any
|
||
// more: it is the shell's shared overlay (controls/QbzTooltip.qml, mounted
|
||
// once by AppShell), which this file drives through showMiniTip /
|
||
// hideMiniTip above. Two reasons, in order of severity:
|
||
//
|
||
// 1. THE OLD ONE WAS BROKEN. It was a QtQuick.Controls `ToolTip` with a
|
||
// custom contentItem, and a ToolTip does not propagate a custom
|
||
// content's implicit size: measured on Qt 6.11.1, an identical Text
|
||
// inside a `Popup` gives implicitContentWidth 157.8 and inside a
|
||
// `ToolTip` gives 1. The Basic style then sizes the popup to
|
||
// `implicitContentWidth + padding`, so the bubble collapsed to ~13x25
|
||
// while the Text — which a Popup never clips — kept painting its full
|
||
// 178px: the label floated over the content pane with no background and
|
||
// a small detached box sat beside it (owner report, reproduced and
|
||
// pixel-sampled before the change).
|
||
// 2. Slint does not put a popup per call site either. It has ONE overlay
|
||
// fed by a global channel (SidebarTooltipState, state.slint:1893;
|
||
// TooltipState, state.slint:4791), precisely so a clipped surface — and
|
||
// this sidebar clips — can still show a bubble outside its own bounds.
|
||
|
||
// Folder flyout (mini rail) and the row context menu — ONE shared instance
|
||
// each, declared at this root, never one per delegate: the tree can hold
|
||
// hundreds of rows. Both are Popups, so their content is reparented to the
|
||
// WINDOW overlay and this root's `clip: true` cannot cut them off.
|
||
SidebarFolderFlyout {
|
||
id: folderFlyout
|
||
onPlaylistActivated: function (id) { root.activePlaylistId = id }
|
||
}
|
||
|
||
// The canonical folder list, parsed once. `foldersJson` is the Playlist
|
||
// Manager's property because the manager owns the rich folder record, but
|
||
// it is kept fresh independently of the manager's document cache
|
||
// (refresh_folders()), so the sidebar can read it before — and without —
|
||
// the manager view ever being opened.
|
||
readonly property var pmFolders: {
|
||
try { return JSON.parse(QbzPlaylistManager.foldersJson) } catch (e) { return [] }
|
||
}
|
||
// Hidden folders are dropped EVERYWHERE in the sidebar tree, so the row
|
||
// menu must not offer them, must not count them toward its search
|
||
// threshold and must not let them suppress its empty state. Derived once,
|
||
// here, and read three times over there.
|
||
readonly property var visibleFolders: root.pmFolders.filter(function (f) {
|
||
return f.isHidden !== true
|
||
})
|
||
|
||
SidebarRowMenu {
|
||
id: rowMenu
|
||
visibleFolders: root.visibleFolders
|
||
}
|
||
|
||
// Right-click menu for a collapsible My QBZ tree row (mixtape/collection):
|
||
// one action, "Hide from sidebar".
|
||
MyQbzSidebarMenu { id: myqbzRowMenu }
|
||
|
||
// My QBZ header "+" — create a new Mixtape or Collection (app-global modal).
|
||
CardMenu {
|
||
id: myqbzPlusMenu
|
||
menuWidth: 190
|
||
entries: [
|
||
{ "label": QbzSession.tr("New Mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
|
||
{ "label": QbzSession.tr("New Collection", QbzSession.trRev), "icon": "library-big", "action": "collection" },
|
||
]
|
||
onPicked: function (a) { QbzShell.myqbzCreateOpen(a) }
|
||
}
|
||
// My QBZ header "..." — a simple name sort plus refresh.
|
||
CardMenu {
|
||
id: myqbzMoreMenu
|
||
menuWidth: 190
|
||
entries: [
|
||
{ "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev), "icon": "chevron-down", "action": "az" },
|
||
{ "label": QbzSession.tr("Name (Z-A)", QbzSession.trRev), "icon": "chevron-up", "action": "za" },
|
||
{ "sep": true },
|
||
{ "label": QbzSession.tr("Refresh", QbzSession.trRev), "icon": "refresh-cw", "action": "refresh" },
|
||
]
|
||
onPicked: function (a) {
|
||
if (a === "az") root.myqbzSortAsc = true
|
||
else if (a === "za") root.myqbzSortAsc = false
|
||
else if (a === "refresh") QbzShell.myqbzRefreshTree()
|
||
}
|
||
}
|
||
|
||
// #771: the open width is the user's (240..480, persisted); the other
|
||
// two states keep their fixed rails.
|
||
width: QbzShell.sidebarState === 2 ? 0
|
||
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
|
||
: QbzShell.sidebarWidth
|
||
/// Set by AppShell while its resize handle is pressed: the width follows
|
||
/// the pointer directly, the state snaps still animate.
|
||
property bool resizing: false
|
||
// surface-card @ 0.5 while the ambient background is active (phase 14).
|
||
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||
readonly property bool ambientOn: theme.ambientOn
|
||
|
||
// Square edges; clip keeps content from spilling while the width
|
||
// animates (same as the Slint root).
|
||
clip: true
|
||
|
||
// While the pointer drives the width the Behavior is off (it would lag
|
||
// the mouse); a STATE snap during that drag (open <-> mini <-> closed)
|
||
// still animates, because the bridge raises `sidebarSnapping` right
|
||
// before it changes the state and lowers it on the next pointer-follow.
|
||
Behavior on width {
|
||
enabled: !root.resizing || QbzShell.sidebarSnapping
|
||
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
||
}
|
||
|
||
// 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
|
||
// 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)
|
||
// A section with no surface behind it renders as unavailable rather
|
||
// than as a live row that clicks into nothing. Every section is live
|
||
// today; the treatment stays for the next stub.
|
||
readonly property bool isEnabled: section && section.enabled
|
||
readonly property bool lit: isActive || (navArea.containsMouse && isEnabled)
|
||
|
||
width: parent ? parent.width : 0
|
||
// Mini: a SQUARE row that fills the 34px padded track (theme token,
|
||
// rail 50 - 2*8). Open keeps Slint's 34px (Sidebar.slint:575).
|
||
height: root.mini ? theme.sidebarMiniRow : 34
|
||
radius: 6
|
||
opacity: isEnabled ? 1.0 : 0.45
|
||
color: lit ? theme.surfaceHover : "transparent"
|
||
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: root.mini ? 0 : 8
|
||
anchors.rightMargin: root.mini ? 0 : 8
|
||
spacing: 10
|
||
|
||
Item {
|
||
width: root.mini ? parent.width : 16
|
||
height: parent.height
|
||
// Baked glyph, or the section's own raw image when it carries
|
||
// one (My QBZ branding) — Sidebar.slint:601-612.
|
||
NavSectionGlyph {
|
||
section: navRow.section
|
||
size: 16
|
||
anchors.centerIn: parent
|
||
tintName: navRow.lit ? "textPrimary" : "secondary"
|
||
}
|
||
}
|
||
Text {
|
||
visible: !root.mini
|
||
height: parent.height
|
||
width: parent.width - (root.mini ? 0 : 26)
|
||
text: navRow.section ? navRow.section.label : ""
|
||
color: navRow.lit ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
font.weight: theme.weightMedium
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: navArea
|
||
anchors.fill: parent
|
||
enabled: navRow.isEnabled
|
||
hoverEnabled: navRow.isEnabled
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
navFlyout.openBeside(navRow, navRow.section, root.mini)
|
||
// Opt-in "click lands on the first tab" — a no-op while the
|
||
// Appearance toggle is off, which is the default.
|
||
navFlyout.sectionClicked(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, root.mini)
|
||
} else if (navRow.section && navFlyout.openId === navRow.section.id) {
|
||
// Only the row owning the open menu clears the flag.
|
||
navFlyout.triggerHovered = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A DIRECT destination row — the port of SidebarDirectRow
|
||
// (Sidebar.slint:646-685). Same 34px metrics, same radius, same lit
|
||
// treatment as NavRow above; what it does NOT have is the flyout. It
|
||
// navigates on click, so there is no catalog `section` behind it — which
|
||
// is also why the glyph goes to QbzIcon directly rather than through
|
||
// shell/NavSectionGlyph.qml (that component's whole input IS a section).
|
||
// `shopping-bag` is a registered tint master (icon_tint_qt.rs:261), so it
|
||
// is available in every tint under all 36 themes.
|
||
//
|
||
// IT MUST NEVER TOUCH `navFlyout.triggerHovered`, and the reason is the
|
||
// opposite of the obvious one. The idle-close timer is
|
||
// `running: panel.visible && !panel.menuHovered` (NavFlyout.qml:285), so
|
||
// with nothing open the flag pins nothing. The hazard is setting it while
|
||
// ANOTHER section's panel IS open: it feeds `menuHovered` (:368-369),
|
||
// which halts the 1s idle-close and leaves that flyout hanging open for as
|
||
// long as the pointer rests on this row. Hovering a direct row is not a
|
||
// flyout event at all.
|
||
component DirectRow: Rectangle {
|
||
id: directRow
|
||
// A QbzIcon glyph name (not a section) and its label.
|
||
property string iconName: ""
|
||
property string label: ""
|
||
property bool active: false
|
||
signal activated()
|
||
|
||
readonly property bool lit: directRow.active || directArea.containsMouse
|
||
|
||
width: parent ? parent.width : 0
|
||
// Mini: the SAME square track the nav rows use, so a direct row and a
|
||
// section row cannot land on different centre lines.
|
||
height: root.mini ? theme.sidebarMiniRow : 34
|
||
radius: 6
|
||
color: directRow.lit ? theme.surfaceHover : "transparent"
|
||
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: root.mini ? 0 : 8
|
||
anchors.rightMargin: root.mini ? 0 : 8
|
||
spacing: 10
|
||
|
||
Item {
|
||
width: root.mini ? parent.width : 16
|
||
height: parent.height
|
||
QbzIcon {
|
||
name: directRow.iconName
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: directRow.lit ? "textPrimary" : "secondary"
|
||
}
|
||
}
|
||
Text {
|
||
visible: !root.mini
|
||
height: parent.height
|
||
width: parent.width - (root.mini ? 0 : 26)
|
||
text: directRow.label
|
||
color: directRow.lit ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
font.weight: theme.weightMedium
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: directArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: directRow.activated()
|
||
}
|
||
}
|
||
|
||
// A collapsible My QBZ tree HEADER: "My QBZ" / "Mixtapes" / "Collections".
|
||
// TWO hit-areas by owner intent — the TEXT is a link to that surface, the
|
||
// CHEVRON only collapses its list. `indent` nests the section headers under
|
||
// the parent. Muted, discreet; not a full nav row.
|
||
component MyQbzHeader: Rectangle {
|
||
id: mqHeader
|
||
property string label: ""
|
||
property bool expanded: true
|
||
property int indent: 0
|
||
property bool showChevron: true
|
||
signal linkClicked()
|
||
signal toggle()
|
||
|
||
width: parent ? parent.width : 0
|
||
height: 30
|
||
radius: 6
|
||
color: (mqLinkArea.containsMouse || mqChevArea.containsMouse)
|
||
? theme.surfaceHover : "transparent"
|
||
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 8 + mqHeader.indent
|
||
anchors.rightMargin: 4
|
||
spacing: 4
|
||
|
||
Text {
|
||
id: mqLinkText
|
||
width: parent.width - 26
|
||
height: parent.height
|
||
text: mqHeader.label
|
||
color: mqLinkArea.containsMouse ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: mqHeader.indent > 0 ? 12 : 13
|
||
font.weight: theme.weightMedium
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
MouseArea {
|
||
id: mqLinkArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: mqHeader.linkClicked()
|
||
}
|
||
}
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
visible: mqHeader.showChevron
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: mqChevArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||
QbzIcon {
|
||
name: mqHeader.expanded ? "chevron-down" : "chevron-right"
|
||
width: 14
|
||
height: 14
|
||
anchors.centerIn: parent
|
||
tintName: mqChevArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: mqChevArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: mqHeader.toggle()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A collapsible My QBZ tree ITEM: one mixtape/collection. Click opens its
|
||
// detail (openCard); right-click offers "Hide from sidebar".
|
||
component MyQbzItem: Rectangle {
|
||
id: mqItem
|
||
property string itemId: ""
|
||
property string label: ""
|
||
property bool isActive: false
|
||
|
||
width: parent ? parent.width : 0
|
||
height: 28
|
||
radius: 6
|
||
color: (mqItemArea.containsMouse || mqItem.isActive)
|
||
? theme.surfaceHover : "transparent"
|
||
|
||
Row {
|
||
anchors.fill: parent
|
||
// Indented past the section header's text so items read as children.
|
||
anchors.leftMargin: 26
|
||
anchors.rightMargin: 8
|
||
spacing: 8
|
||
|
||
QbzIcon {
|
||
name: "music"
|
||
width: 13
|
||
height: 13
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: (mqItemArea.containsMouse || mqItem.isActive) ? "textPrimary" : "muted"
|
||
}
|
||
Text {
|
||
width: parent.width - 21
|
||
height: parent.height
|
||
text: mqItem.label
|
||
color: (mqItemArea.containsMouse || mqItem.isActive)
|
||
? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: mqItemArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: function (mouse) {
|
||
if (mouse.button === Qt.RightButton) {
|
||
var lx = mqItem.width - myqbzRowMenu.menuWidth
|
||
myqbzRowMenu.openForItem(mqItem.itemId, mqItem, Math.max(0, lx), 26)
|
||
} else {
|
||
QbzShell.myqbzOpenCard(mqItem.itemId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Column {
|
||
anchors.fill: parent
|
||
// Mini left/right are ASYMMETRIC on purpose — see `miniPadLeft` at the
|
||
// top of this file for the measurement. Top stays 8 (nothing above the
|
||
// rail competes with it vertically).
|
||
anchors.leftMargin: root.mini ? root.miniPadLeft : theme.spacingMd
|
||
anchors.rightMargin: root.mini ? root.miniPadRight : theme.spacingMd
|
||
anchors.topMargin: root.mini ? 8 : theme.spacingMd
|
||
// Per-side padding so Large can zero ONLY the bottom (Sidebar.slint:719):
|
||
// the reserved dock band then reaches the true window bottom-left and the
|
||
// cover sits flush (the L corner). Left/right/top are unchanged.
|
||
anchors.bottomMargin: root.largeDockActive ? 0 : (root.mini ? 8 : theme.spacingMd)
|
||
spacing: theme.spacingMd
|
||
|
||
// ---- Section nav -------------------------------------------
|
||
Column {
|
||
id: navColumn
|
||
// Section-nav placement: OFF moves the whole block to the header.
|
||
// A Column skips invisible children entirely (no phantom spacing),
|
||
// so this is the `if` mount-site gate Slint uses.
|
||
visible: QbzShell.navInSidebar
|
||
width: parent.width
|
||
spacing: 2
|
||
|
||
// 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 is `qobuz:
|
||
// false` in the catalog, i.e. it stays mounted while offline —
|
||
// Slint does not offline-gate its row either
|
||
// (Sidebar.slint:779-792, unlike the Purchases row right after it).
|
||
Repeater {
|
||
model: navFlyout.sections
|
||
delegate: NavRow {
|
||
required property var modelData
|
||
section: modelData
|
||
// Offline-gated as before, PLUS the opt-in suppressor: with
|
||
// the collapsible tree on and "Hide standard My QBZ menu
|
||
// entry" checked, the flat My QBZ section row gives way to
|
||
// the tree block mounted right below this Repeater.
|
||
visible: !(modelData.qobuz && QbzSession.offline)
|
||
&& !(modelData.id === "myqbz" && root.hideStandardMyqbz)
|
||
}
|
||
}
|
||
|
||
// ---- Collapsible My QBZ tree (opt-in) ----------------------
|
||
// Sits right under the four section rows (My QBZ is the last of
|
||
// them), so its expanded tree reads as that section's contents.
|
||
// Hidden on the mini rail and whenever the opt-in is off. The
|
||
// parent "My QBZ" header collapses the whole block; each section
|
||
// header links to its grid and collapses its own list.
|
||
Column {
|
||
id: myqbzTree
|
||
visible: !root.mini && root.myqbzTreeOn
|
||
width: parent.width
|
||
spacing: 2
|
||
|
||
// Parent header TOOLBAR — Playlists-parity: branding icon +
|
||
// title (a link to the My QBZ surface) on the LEFT; search /
|
||
// new / more / collapse FLOATING on the right, their chevron
|
||
// aligned to the playlist carets (rightMargin 4 → the 22px box's
|
||
// 15px icon centres on width-15, same as the row carets and the
|
||
// Mixtapes/Collections sub-chevrons).
|
||
Item {
|
||
width: parent.width
|
||
height: 34
|
||
|
||
// Branding icon — user image, else the baked qbz-symbolic.
|
||
Item {
|
||
id: myqbzBrandIcon
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 18
|
||
height: 18
|
||
Image {
|
||
visible: navFlyout.brandingIconPath !== ""
|
||
source: navFlyout.brandingIconPath
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
fillMode: Image.PreserveAspectFit
|
||
mipmap: true
|
||
}
|
||
QbzIcon {
|
||
visible: navFlyout.brandingIconPath === ""
|
||
name: "qbz-symbolic"
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: "secondary"
|
||
}
|
||
}
|
||
|
||
// Floating right-hand controls.
|
||
Row {
|
||
id: myqbzHeaderBtns
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 4
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 4
|
||
|
||
// Search toggle.
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: (myqbzSearchArea.containsMouse || root.myqbzSearchOpen) ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "search"
|
||
width: 15
|
||
height: 15
|
||
anchors.centerIn: parent
|
||
tintName: (myqbzSearchArea.containsMouse || root.myqbzSearchOpen) ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: myqbzSearchArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
root.myqbzSearchOpen = !root.myqbzSearchOpen
|
||
if (!root.myqbzSearchOpen)
|
||
myqbzSearchEdit.clear()
|
||
else
|
||
myqbzSearchEdit.focusField()
|
||
}
|
||
}
|
||
}
|
||
// New (+).
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: myqbzPlusArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "plus"
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: myqbzPlusArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: myqbzPlusArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: myqbzPlusMenu.openBelowLeft(myqbzPlusArea)
|
||
}
|
||
}
|
||
// More (…) — sort + refresh.
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: myqbzMoreArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "ellipsis"
|
||
width: 15
|
||
height: 15
|
||
anchors.centerIn: parent
|
||
tintName: myqbzMoreArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: myqbzMoreArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: myqbzMoreMenu.openBelowLeft(myqbzMoreArea)
|
||
}
|
||
}
|
||
// Collapse chevron (down = expanded).
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: myqbzChevArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: root.myqbzCollapsed ? "chevron-right" : "chevron-down"
|
||
width: 15
|
||
height: 15
|
||
anchors.centerIn: parent
|
||
tintName: myqbzChevArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: myqbzChevArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzBridge.settingsBool("myqbz-collapsed", !root.myqbzCollapsed)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Title — a link to the My QBZ surface (Collections). Fills
|
||
// between the icon and the floating controls.
|
||
Text {
|
||
visible: !root.myqbzSearchOpen
|
||
anchors.left: myqbzBrandIcon.right
|
||
anchors.leftMargin: 8
|
||
anchors.right: myqbzHeaderBtns.left
|
||
anchors.rightMargin: 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: navFlyout.brandingLabel
|
||
color: myqbzTitleArea.containsMouse ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
font.weight: theme.weightMedium
|
||
elide: Text.ElideRight
|
||
MouseArea {
|
||
id: myqbzTitleArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzShell.navigateTo("collections")
|
||
}
|
||
}
|
||
// Inline search field (filters the tree client-side). The
|
||
// shared search box: Escape empties it AND folds it away,
|
||
// the cross empties it and keeps the caret.
|
||
QbzSearchField {
|
||
id: myqbzSearchEdit
|
||
visible: root.myqbzSearchOpen
|
||
anchors.left: myqbzBrandIcon.right
|
||
anchors.leftMargin: 8
|
||
anchors.right: myqbzHeaderBtns.left
|
||
anchors.rightMargin: 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
height: 22
|
||
radius: 4
|
||
showGlyph: false
|
||
sidePadding: 6
|
||
fontPx: 12
|
||
placeholder: QbzSession.tr("Search My QBZ", QbzSession.trRev)
|
||
onEdited: function (text) { root.myqbzSearch = text }
|
||
onEscaped: root.myqbzSearchOpen = false
|
||
}
|
||
}
|
||
|
||
// Mixtapes section (gated by its own opt-in).
|
||
MyQbzHeader {
|
||
visible: !root.myqbzCollapsed && root.myqbzShowMixtapes
|
||
width: parent.width
|
||
indent: 12
|
||
label: QbzSession.tr("Mixtapes", QbzSession.trRev)
|
||
expanded: !root.myqbzMixtapesCollapsed
|
||
onLinkClicked: QbzShell.navigateTo("mixtapes")
|
||
onToggle: QbzBridge.settingsBool("myqbz-mixtapes-collapsed", !root.myqbzMixtapesCollapsed)
|
||
}
|
||
Repeater {
|
||
model: (!root.myqbzCollapsed && root.myqbzShowMixtapes
|
||
&& !root.myqbzMixtapesCollapsed) ? root.myqbzMixtapes : []
|
||
delegate: MyQbzItem {
|
||
required property var modelData
|
||
width: myqbzTree.width
|
||
itemId: String(modelData.id)
|
||
label: String(modelData.name)
|
||
}
|
||
}
|
||
Text {
|
||
visible: !root.myqbzCollapsed && root.myqbzShowMixtapes
|
||
&& !root.myqbzMixtapesCollapsed && root.myqbzMixtapes.length === 0
|
||
width: parent.width
|
||
leftPadding: 26
|
||
height: 22
|
||
text: QbzSession.tr("No mixtapes yet.", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 11
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
// Collections section (gated by its own opt-in).
|
||
MyQbzHeader {
|
||
visible: !root.myqbzCollapsed && root.myqbzShowCollections
|
||
width: parent.width
|
||
indent: 12
|
||
label: QbzSession.tr("Collections", QbzSession.trRev)
|
||
expanded: !root.myqbzCollectionsCollapsed
|
||
onLinkClicked: QbzShell.navigateTo("collections")
|
||
onToggle: QbzBridge.settingsBool("myqbz-collections-collapsed", !root.myqbzCollectionsCollapsed)
|
||
}
|
||
Repeater {
|
||
model: (!root.myqbzCollapsed && root.myqbzShowCollections
|
||
&& !root.myqbzCollectionsCollapsed) ? root.myqbzCollections : []
|
||
delegate: MyQbzItem {
|
||
required property var modelData
|
||
width: myqbzTree.width
|
||
itemId: String(modelData.id)
|
||
label: String(modelData.name)
|
||
}
|
||
}
|
||
Text {
|
||
visible: !root.myqbzCollapsed && root.myqbzShowCollections
|
||
&& !root.myqbzCollectionsCollapsed && root.myqbzCollections.length === 0
|
||
width: parent.width
|
||
leftPadding: 26
|
||
height: 22
|
||
text: QbzSession.tr("No collections yet.", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 11
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
// Purchases — LAST in the block and the only non-section row here
|
||
// (Sidebar.slint:794-805). Opt-in, hidden while offline (the
|
||
// surface needs the Qobuz session), and ACTIVE on both purchase
|
||
// routes so the highlight survives the click into a purchased
|
||
// album's detail.
|
||
//
|
||
// The click only navigates. Entering the list is the VIEW's job:
|
||
// `openList()` pairs with `leave()` on the view's own
|
||
// mount/unmount (§G.1), and Back reaches this route without ever
|
||
// passing through this row — a row that pre-loaded would leave the
|
||
// history path unloaded and double-fetch the direct path.
|
||
DirectRow {
|
||
visible: root.purchasesVisible
|
||
iconName: "shopping-bag"
|
||
label: QbzSession.tr("Purchases", QbzSession.trRev)
|
||
active: QbzShell.currentView === "purchases"
|
||
|| QbzShell.currentView === "purchase-album"
|
||
onActivated: QbzShell.navigateTo("purchases")
|
||
}
|
||
|
||
// Purchases DOWNLOADING right now. A download is process-wide
|
||
// and survives leaving its page (purchases_qt's store); this is
|
||
// how the user finds it again. Expanded: one row per album with
|
||
// a thin progress bar. Mini: one ring for the whole set, with
|
||
// the per-album detail in its hover bubble. Absent while idle.
|
||
Item {
|
||
id: dlBlock
|
||
readonly property var rows: root.activeDownloads
|
||
visible: root.purchasesVisible && rows.length > 0
|
||
width: parent.width
|
||
height: !visible ? 0 : (root.mini ? theme.sidebarMiniRow : dlCol.implicitHeight)
|
||
|
||
Column {
|
||
id: dlCol
|
||
visible: !root.mini
|
||
width: parent.width
|
||
spacing: 2
|
||
Repeater {
|
||
model: root.mini ? [] : dlBlock.rows
|
||
delegate: Rectangle {
|
||
id: dlRow
|
||
required property var modelData
|
||
width: parent ? parent.width : 0
|
||
height: 34
|
||
radius: 6
|
||
color: dlArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
Column {
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.leftMargin: 8
|
||
anchors.rightMargin: 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 4
|
||
Row {
|
||
width: parent.width
|
||
spacing: 6
|
||
QbzIcon {
|
||
name: "cloud-download"
|
||
width: 14
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: "accent"
|
||
}
|
||
Text {
|
||
width: parent.width - 20 - 34
|
||
text: dlRow.modelData.title || QbzSession.tr("Purchases", QbzSession.trRev)
|
||
color: theme.textSecondary
|
||
font.pixelSize: 12
|
||
elide: Text.ElideRight
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
Text {
|
||
width: 28
|
||
text: (dlRow.modelData.percent || 0) + "%"
|
||
color: theme.textMuted
|
||
font.pixelSize: 11
|
||
horizontalAlignment: Text.AlignRight
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 3
|
||
radius: 1.5
|
||
color: theme.borderSubtle
|
||
Rectangle {
|
||
width: parent.width * Math.max(0, Math.min(1, (dlRow.modelData.percent || 0) / 100))
|
||
height: parent.height
|
||
radius: parent.radius
|
||
color: theme.accent
|
||
}
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: dlArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
QbzPurchases.openAlbum(dlRow.modelData.id || "")
|
||
QbzShell.navigateTo("purchase-album")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mini: the ring, with the detail on hover.
|
||
Item {
|
||
visible: root.mini
|
||
width: parent.width
|
||
height: parent.height
|
||
QbzProgressRing {
|
||
anchors.centerIn: parent
|
||
size: 18
|
||
value: root.activeDownloadsOverall
|
||
}
|
||
MouseArea {
|
||
id: ringArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzShell.navigateTo("purchases")
|
||
}
|
||
ToolTip {
|
||
visible: ringArea.containsMouse
|
||
delay: 0
|
||
timeout: -1
|
||
text: root.activeDownloadsSummary
|
||
x: parent.width + 8
|
||
y: Math.round((parent.height - height) / 2)
|
||
contentItem: Text {
|
||
text: root.activeDownloadsSummary
|
||
color: theme.textPrimary
|
||
font.pixelSize: 11
|
||
font.weight: theme.weightMedium
|
||
leftPadding: 9
|
||
rightPadding: 9
|
||
topPadding: 6
|
||
bottomPadding: 6
|
||
}
|
||
background: Rectangle {
|
||
color: theme.surfaceElevated
|
||
radius: 4
|
||
border.width: 1
|
||
border.color: theme.borderMuted
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Hairline under the section nav — mounted with the nav block, not
|
||
// on its own (Sidebar.slint:817 gates it on the same flag).
|
||
Rectangle {
|
||
visible: QbzShell.navInSidebar
|
||
width: parent.width
|
||
height: 1
|
||
color: theme.borderSubtle
|
||
}
|
||
|
||
// ---- Playlists header toolbar (hidden in the mini state) -----
|
||
Row {
|
||
visible: !root.mini
|
||
width: parent.width
|
||
height: 22
|
||
spacing: 4
|
||
|
||
// Title (hidden while the search input is open).
|
||
Text {
|
||
visible: !root.searchOpen
|
||
width: parent.width - 4 * 26
|
||
height: parent.height
|
||
text: QbzSession.tr("PLAYLISTS", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 10
|
||
font.letterSpacing: 1
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
// Inline search input (filters entries, recursive). The shared
|
||
// search box: Escape empties it AND folds it back into the title,
|
||
// the cross empties it and keeps the caret.
|
||
QbzSearchField {
|
||
id: searchEdit
|
||
visible: root.searchOpen
|
||
width: parent.width - 4 * 26
|
||
height: 22
|
||
radius: 4
|
||
showGlyph: false
|
||
sidePadding: 6
|
||
fontPx: 12
|
||
placeholder: QbzSession.tr("Search playlists", QbzSession.trRev)
|
||
onEdited: function (text) { QbzShell.sidebarSearch(text) }
|
||
onEscaped: root.searchOpen = false
|
||
}
|
||
|
||
// Search toggle.
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: (searchBtnArea.containsMouse || root.searchOpen) ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "search"
|
||
width: 15
|
||
height: 15
|
||
anchors.centerIn: parent
|
||
tintName: (searchBtnArea.containsMouse || root.searchOpen) ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: searchBtnArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
root.searchOpen = !root.searchOpen
|
||
if (!root.searchOpen)
|
||
searchEdit.clear()
|
||
else
|
||
searchEdit.focusField()
|
||
}
|
||
}
|
||
}
|
||
// New playlist (+) — opens controls/PlaylistCreateModal.qml, the
|
||
// port of primitives/CreatePlaylistModal.slint.
|
||
//
|
||
// IT USED TO BE A ONE-CALL SHORTCUT (`QbzShell.createPlaylist()`)
|
||
// that created a playlist named "New Playlist" and navigated to
|
||
// it. That shortcut had no way to set the name, the description,
|
||
// the folder or the public flag — and, the reason it counts as a
|
||
// regression rather than a rough edge, no way to set OFFLINE-ONLY.
|
||
// It derived that from connectivity, so an online user could not
|
||
// create a local playlist at all.
|
||
//
|
||
// NOT offline-gated, and that is deliberate: the modal's own D8
|
||
// arm locks the offline-only toggle ON while the app is offline
|
||
// and creates a LOCAL playlist. Dimming this row would take the
|
||
// ONE way an account-less user makes a playlist away from exactly
|
||
// the state they live in.
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: plusArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "plus"
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: plusArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: plusArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzPlaylistEdit.openCreate()
|
||
}
|
||
}
|
||
// Sort / more (...) menu.
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: sortBtnArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: "ellipsis"
|
||
width: 15
|
||
height: 15
|
||
anchors.centerIn: parent
|
||
tintName: sortBtnArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: sortBtnArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: sortMenu.open()
|
||
}
|
||
Popup {
|
||
id: sortMenu
|
||
// Slint puts this panel's left edge at `toolbar.width -
|
||
// 40px`. Resolved: the open sidebar's content column is
|
||
// 240 - 2*16 = 208 wide and the "..." button occupies
|
||
// 160…182 of it, so 208 - 40 = 168 is the button's own
|
||
// x + 8. This Popup is parented to the BUTTON, not to the
|
||
// row, so the 1:1 value is x: 8 — the old `parent.width +
|
||
// 18` (= 40) put it 32px too far right.
|
||
x: 8
|
||
y: 26
|
||
width: 200
|
||
padding: 6
|
||
closePolicy: Popup.CloseOnPressOutside
|
||
background: Rectangle {
|
||
// OPAQUE UNDER AMBIENT, unlike this root. Sidebar.slint
|
||
// :920-922 gives the panel the sidebar's own ambient
|
||
// treatment (surface-card @50%), and it was ported 1:1 —
|
||
// which made this the ONE menu surface in the app that
|
||
// goes translucent while the dynamic background is on.
|
||
// Every other menu/flyout (QbzContextMenu and therefore
|
||
// CardMenu / SidebarRowMenu / ViewModeMenu / the audio
|
||
// flyout, plus NavFlyout, SidebarFolderFlyout,
|
||
// QconnectFlyout, GenreFilterPopup, LocalFilterPopup)
|
||
// paints a flat opaque surface. A menu is TEXT the user
|
||
// is reading against artwork that moves under it, not
|
||
// chrome, so the outlier lost: owner ruling 2026-08-19,
|
||
// "la lectura se complica con la transparencia".
|
||
color: theme.surfaceCard
|
||
radius: 8
|
||
border.width: 1
|
||
border.color: theme.borderSubtle
|
||
}
|
||
contentItem: Column {
|
||
spacing: 1
|
||
Row {
|
||
leftPadding: 8
|
||
topPadding: 4
|
||
bottomPadding: 2
|
||
spacing: 6
|
||
QbzIcon { name: "arrow-up-down"; width: 13; height: 13; tintName: "muted" }
|
||
Text {
|
||
text: QbzSession.tr("Sort by", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 11
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
Repeater {
|
||
model: [
|
||
{ "opt": "name", "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev) },
|
||
{ "opt": "recent", "label": QbzSession.tr("Recent", QbzSession.trRev) },
|
||
{ "opt": "tracks", "label": QbzSession.tr("# of tracks", QbzSession.trRev) },
|
||
{ "opt": "playcount", "label": QbzSession.tr("Play Count", QbzSession.trRev) },
|
||
{ "opt": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev) },
|
||
]
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
width: parent ? parent.width : 0
|
||
height: 30
|
||
radius: 5
|
||
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 8
|
||
anchors.rightMargin: 8
|
||
spacing: 6
|
||
Text {
|
||
width: parent.width - 20
|
||
height: parent.height
|
||
text: modelData.label
|
||
color: theme.textSecondary
|
||
font.pixelSize: 13
|
||
font.weight: QbzShell.sidebarSortBy === modelData.opt ? theme.weightSemibold : theme.weightRegular
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
QbzIcon {
|
||
visible: QbzShell.sidebarSortBy === modelData.opt
|
||
name: QbzShell.sidebarSortAsc ? "chevron-up" : "chevron-down"
|
||
width: 13
|
||
height: 13
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: "accent"
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: sortOptArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
QbzShell.sidebarSetSort(modelData.opt)
|
||
sortMenu.close()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||
// New folder / Manage playlists / Import / Refresh —
|
||
// all four LIVE (the Refresh row was missing from this
|
||
// port entirely and is restored here).
|
||
//
|
||
// `on` is the enabled flag and it drives everything at
|
||
// once: the hover fill, the pointer cursor, the glyph
|
||
// tint, the 0.4 dim AND whether the MouseArea exists.
|
||
// There is no way to light one row and leave another
|
||
// inert without it.
|
||
//
|
||
// Import is the only row that can be off, and only
|
||
// while offline — importing a playlist is Qobuz-only
|
||
// work.
|
||
//
|
||
// REFRESH STAYS LIT OFFLINE (reversed 2026-07-31 on the
|
||
// owner's smoke). The earlier decision dimmed it
|
||
// because QbzShell.reloadSidebar() called
|
||
// crate::reload_sidebar(), which early-returns offline
|
||
// — a lit row would have been a no-op, and a control
|
||
// that renders and no-ops is a defect. The bridge now
|
||
// calls crate::reload_sidebar_including_local()
|
||
// instead, which offline re-reads folders, folder
|
||
// membership, the hidden set and the LOCAL playlists
|
||
// from library.db and republishes (preserving the
|
||
// cached Qobuz set). That is a real refresh, and it is
|
||
// precisely the tree an account-less user owns — the
|
||
// owner hit an empty offline sidebar with no way to
|
||
// rebuild it.
|
||
//
|
||
// Every arm closes the menu FIRST. Slint's PopupWindow
|
||
// dismisses on any click, including inside itself,
|
||
// which is why its own items never call close(); Qt's
|
||
// CloseOnPressOutside does the opposite. This is the
|
||
// single most common silent divergence in this seam.
|
||
Repeater {
|
||
model: [
|
||
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev),
|
||
"act": "newFolder", "on": true },
|
||
{ "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev),
|
||
"act": "manage", "on": true },
|
||
// LIVE. The importer landed with this seam
|
||
// (src/playlist_import_bridge.rs +
|
||
// controls/PlaylistImportModal.qml), so contract
|
||
// §10 Q2 resolves to "land the importer" and
|
||
// there is no `typeof QbzPlaylistImport` guard
|
||
// here: the row is a real row, dimmed only
|
||
// while offline — the only row that dims.
|
||
{ "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev),
|
||
"act": "import", "on": !QbzSession.offline },
|
||
{ "icon": "refresh-cw", "label": QbzSession.tr("Refresh", QbzSession.trRev),
|
||
"act": "refresh", "on": true },
|
||
]
|
||
delegate: Rectangle {
|
||
id: actRow
|
||
required property var modelData
|
||
readonly property bool rowEnabled: modelData.on === true
|
||
width: parent ? parent.width : 0
|
||
height: 30
|
||
radius: 5
|
||
color: (actRow.rowEnabled && actArea.containsMouse)
|
||
? theme.surfaceHover : "transparent"
|
||
opacity: actRow.rowEnabled ? 1.0 : 0.4
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 8
|
||
anchors.rightMargin: 8
|
||
spacing: 8
|
||
QbzIcon {
|
||
name: actRow.modelData.icon
|
||
width: 14
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: actRow.rowEnabled ? "secondary" : "muted"
|
||
}
|
||
Text {
|
||
width: parent.width - 22
|
||
height: parent.height
|
||
text: actRow.modelData.label
|
||
color: actRow.rowEnabled ? theme.textSecondary : theme.textMuted
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: actArea
|
||
anchors.fill: parent
|
||
enabled: actRow.rowEnabled
|
||
hoverEnabled: actRow.rowEnabled
|
||
cursorShape: actRow.rowEnabled
|
||
? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: {
|
||
sortMenu.close()
|
||
var a = actRow.modelData.act
|
||
if (a === "newFolder") QbzFolderEdit.openCreate()
|
||
else if (a === "manage") QbzPlaylistManager.navigate()
|
||
else if (a === "import") QbzPlaylistImport.open()
|
||
else if (a === "refresh") QbzShell.reloadSidebar()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Collapse chevron (down = expanded, right = collapsed).
|
||
Rectangle {
|
||
width: 22
|
||
height: 22
|
||
radius: 4
|
||
color: collapseArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
QbzIcon {
|
||
name: root.playlistsCollapsed ? "chevron-right" : "chevron-down"
|
||
width: 15
|
||
height: 15
|
||
anchors.centerIn: parent
|
||
tintName: collapseArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: collapseArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzBridge.settingsBool("sidebar-playlists-collapsed", !root.playlistsCollapsed)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Playlist tree (folders + playlists, sidebar_qt.rs) --------
|
||
Item {
|
||
visible: !root.playlistsCollapsed
|
||
width: parent.width
|
||
// This is the element that FILLS (Sidebar.slint gives the playlist
|
||
// Rectangle `vertical-stretch: 1`): everything above it — the nav
|
||
// block, the hairline, the 22px toolbar — takes its natural height
|
||
// and the list eats the rest, down to the Column's bottom margin.
|
||
// `root.dockReserve` keeps the rows clear of the Large-NPB cover
|
||
// (0 in every other mode). Clamped so a short window shrinks the
|
||
// list to nothing instead of giving it a negative height.
|
||
//
|
||
// There used to be a trailing `Item { height: 0 }` "filler" after
|
||
// this one and a matching `- 16` here to pay for the Column spacing
|
||
// it introduced. Net effect: the list stopped 16px short and the
|
||
// sidebar read as too short against Slint. Both are gone — the
|
||
// list is the last child, so its bottom IS the Column's bottom.
|
||
height: Math.max(0, parent.height - y - root.dockReserve)
|
||
|
||
Flickable {
|
||
id: plFlick
|
||
anchors.fill: parent
|
||
clip: true
|
||
contentWidth: width
|
||
contentHeight: plColumn.implicitHeight
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
|
||
Column {
|
||
id: plColumn
|
||
width: parent.width
|
||
spacing: 2
|
||
|
||
Repeater {
|
||
model: root.entries
|
||
delegate: Rectangle {
|
||
id: plRow
|
||
required property var modelData
|
||
property bool isFolder: modelData.kind === "folder"
|
||
// Slint SidebarRow.use-collage: opt-OUT setting AND
|
||
// at least one cover; otherwise the list-music glyph.
|
||
property bool useCollage: !isFolder
|
||
&& QbzShell.sidebarPlaylistCollage
|
||
&& modelData.covers.length > 0
|
||
property bool isActive: !isFolder && modelData.id === root.activePlaylistId
|
||
// Track-drag drop target (Sidebar.slint SidebarRow):
|
||
// the row self-detects the drop from the shared
|
||
// window-coord pointer and claims/releases it.
|
||
property bool dropHot: false
|
||
function recomputeDrop() {
|
||
if (isFolder || !QbzShell.dragActive) {
|
||
if (dropHot) { QbzShell.dragSetOver(""); dropHot = false }
|
||
return
|
||
}
|
||
const tl = mapToItem(null, 0, 0)
|
||
const hot = QbzShell.dragX >= tl.x && QbzShell.dragX <= tl.x + width
|
||
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= tl.y + height
|
||
if (hot && !dropHot) QbzShell.dragSetOver(modelData.id)
|
||
else if (!hot && dropHot) QbzShell.dragSetOver("")
|
||
dropHot = hot
|
||
}
|
||
Connections {
|
||
target: QbzShell
|
||
function onDragXChanged() { recomputeDrop() }
|
||
function onDragYChanged() { recomputeDrop() }
|
||
function onDragActiveChanged() { recomputeDrop() }
|
||
}
|
||
width: plColumn.width
|
||
// Mini: nested rows collapse to nothing (their
|
||
// folder opens the flyout instead, Sidebar.slint:
|
||
// 213-215), top-level rows become the SAME square
|
||
// the nav rows are — a 34px row in a 34px track.
|
||
// Slint keeps 32px here; the 2px come from the
|
||
// narrower rail (theme.sidebarMiniRow).
|
||
height: root.mini
|
||
? (modelData.indent ? 0 : theme.sidebarMiniRow)
|
||
: 32
|
||
visible: !(root.mini && modelData.indent)
|
||
radius: 6
|
||
// success-bg + success-border while hot. These used
|
||
// to be the Slint literals "#3fae6a1a" /
|
||
// "#3fae6a4d" copied verbatim, which is a real
|
||
// colour bug: Slint's 8-digit form is #RRGGBBAA and
|
||
// Qt's is #AARRGGBB, so the drop target rendered
|
||
// olive-brown at 25% alpha instead of success green
|
||
// at 15% / 35%. The tokens carry the converted
|
||
// values (theme.successBg = #263fae6a).
|
||
color: dropHot ? theme.successBg
|
||
: ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent")
|
||
border.width: dropHot ? 1 : 0
|
||
border.color: theme.successBorder
|
||
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: root.mini ? 0 : (modelData.indent ? 24 : 8)
|
||
anchors.rightMargin: root.mini ? 0 : 6
|
||
spacing: 8
|
||
|
||
// Leading icon slot: 24px fixed while open (so
|
||
// every row's name starts at the same x), the
|
||
// WHOLE row in mini so the lone glyph lands on
|
||
// the rail's centre line — Slint gets this from
|
||
// `alignment: mini ? center : stretch`
|
||
// (Sidebar.slint:230), which a QML Row has no
|
||
// equivalent for. Without it the slot is the
|
||
// only visible child, sits at x=0, and the
|
||
// playlist/folder glyphs render 12px LEFT of
|
||
// the nav glyphs above them (owner screenshot).
|
||
// Same idiom as NavRow's slot above.
|
||
Item {
|
||
width: root.mini ? parent.width : 24
|
||
height: parent.height
|
||
// Folder icon (accent). Every glyph in this
|
||
// slot is placed with rounded arithmetic
|
||
// (Sidebar.slint:245-246): `centerIn` of an
|
||
// odd 15px glyph in an even track lands on a
|
||
// half pixel and blurs it.
|
||
QbzIcon {
|
||
visible: isFolder
|
||
name: modelData.expanded && !root.mini ? "folder-open" : "folder"
|
||
width: 15
|
||
height: 15
|
||
x: Math.round((parent.width - width) / 2)
|
||
y: Math.round((parent.height - height) / 2)
|
||
tintName: "accent"
|
||
}
|
||
// Playlist micro-collage (or list-music
|
||
// glyph) — the port of
|
||
// primitives/SidebarPlaylistCollage.slint:
|
||
// a 20px box, 3px radius, surface-elevated
|
||
// backing, 1px seam.
|
||
//
|
||
// TWO arms, not one. The reference splits
|
||
// on the cover count (.slint:27-68):
|
||
// 1-3 covers -> ONE full-bleed cover, 4+ ->
|
||
// the 2x2 grid. This used to be an
|
||
// unconditional 2x2 of hard-coded 10x10
|
||
// tiles with no seam, so a playlist with a
|
||
// single cover drew one 10x10 tile in the
|
||
// top-left quadrant and left three quarters
|
||
// of the box empty.
|
||
//
|
||
// Covers stay on the host's already-resolved
|
||
// `root.coverMap` (filled by the ONE
|
||
// `sidebarArtworkWindow` dispatch in
|
||
// parseEntries) — this is not a second
|
||
// artwork seam.
|
||
Item {
|
||
id: collage
|
||
visible: useCollage
|
||
width: 20
|
||
height: 20
|
||
x: Math.round((parent.width - width) / 2)
|
||
y: Math.round((parent.height - height) / 2)
|
||
|
||
// `n`, straight off SidebarEntry.cover-count
|
||
// (.slint:24). sidebar_qt.rs already
|
||
// de-duplicates and caps `covers` at 4
|
||
// (SidebarEntry::covers), so this is 0..4
|
||
// and no dedup pass is owed here.
|
||
readonly property int n: useCollage ? plRow.modelData.covers.length : 0
|
||
// .slint:25 `gap: 1px`. NOT the mosaic
|
||
// card's 2px seam — the micro-collage is
|
||
// its own component with its own number.
|
||
readonly property real gap: 1
|
||
|
||
// Backing surface. A Rectangle's OWN
|
||
// rounded fill paints correctly; only its
|
||
// CHILDREN need the mask below, which is
|
||
// exactly the distinction the old code
|
||
// missed.
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
radius: 3
|
||
color: theme.surfaceElevated
|
||
}
|
||
|
||
// The rounded outer shape as a mask
|
||
// texture. `clip: true` in Qt Quick is a
|
||
// RECTANGULAR scissor and IGNORES
|
||
// `radius`, which is why the old
|
||
// `Rectangle { radius: 3; clip: true }`
|
||
// shipped square collages: the fill was
|
||
// round, the four covers painted on top
|
||
// of it were not (owner report). The
|
||
// documented fix is the `layer.enabled` +
|
||
// `MultiEffect { maskEnabled }` idiom —
|
||
// same mechanism, same constants, as
|
||
// cards/PlaylistCollage.qml:264-305 and
|
||
// theme/RoundedImage.qml.
|
||
Item {
|
||
id: collageMask
|
||
anchors.fill: parent
|
||
visible: false
|
||
// A layer renders even when its item
|
||
// is invisible — that IS the mask
|
||
// idiom — so the gate has to be
|
||
// `useCollage`, not `visible`.
|
||
// PlaylistCollage.qml gates its own
|
||
// two layers on `tiles.length > 0`
|
||
// for the same reason, and it matters
|
||
// MORE here: this delegate is inside
|
||
// a Repeater over the whole playlist
|
||
// tree, so an ungated pair of layers
|
||
// would cost two FBOs on every folder
|
||
// row and every glyph row too.
|
||
layer.enabled: useCollage && !root._noShaders
|
||
layer.smooth: true
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
radius: 3
|
||
color: "#ffffff"
|
||
}
|
||
}
|
||
|
||
// ONE layer + ONE mask for the whole
|
||
// collage, not one per tile. `clip` stays
|
||
// on the container because
|
||
// PreserveAspectCrop paints OUTSIDE its
|
||
// item's bounds unless clipped — it just
|
||
// cannot round on its own.
|
||
Item {
|
||
anchors.fill: parent
|
||
clip: true
|
||
layer.enabled: useCollage && !root._noShaders
|
||
layer.smooth: true
|
||
layer.effect: MultiEffect {
|
||
maskEnabled: true
|
||
maskSource: collageMask
|
||
// 0.5 / 1.0 — MEASURED; the full
|
||
// table is in
|
||
// theme/RoundedImage.qml. Qt's
|
||
// mask is a smoothstep CENTRED on
|
||
// maskThresholdMin of width
|
||
// maskSpreadAtMin, so this is the
|
||
// only pair that maps mask alpha
|
||
// 0..1 onto 0..1. (0.0, 0.0)
|
||
// collapses it to step() and the
|
||
// corners come back HARD; (0.0,
|
||
// > 0) clamps everything opaque
|
||
// and disables the mask outright.
|
||
// Do not "restore the defaults".
|
||
maskThresholdMin: 0.5
|
||
maskSpreadAtMin: 1.0
|
||
}
|
||
|
||
// 1-3 covers -> a single full-bleed
|
||
// cover (.slint:28-33). `image-fit:
|
||
// cover` = PreserveAspectCrop.
|
||
Image {
|
||
anchors.fill: parent
|
||
visible: collage.n >= 1 && collage.n < 4
|
||
// Empty out of arm so the hidden
|
||
// branch holds no texture. Reading
|
||
// `visible` back gives the
|
||
// EFFECTIVE visibility, so this
|
||
// also drops the texture for a row
|
||
// the mini rail collapses — and
|
||
// re-binds when it comes back.
|
||
source: visible ? (root.coverMap[plRow.modelData.covers[0]] || "") : ""
|
||
fillMode: Image.PreserveAspectCrop
|
||
asynchronous: true
|
||
}
|
||
|
||
// 4+ covers -> a 2x2 grid of
|
||
// (size - gap) / 2 tiles with the 1px
|
||
// seam (.slint:36-68). Grid `spacing`
|
||
// IS that seam: 9.5 + 1 + 9.5 = 20.
|
||
Grid {
|
||
anchors.fill: parent
|
||
columns: 2
|
||
spacing: collage.gap
|
||
Repeater {
|
||
// Empty below four — the arm
|
||
// is not merely hidden, it
|
||
// holds no tiles and no
|
||
// textures.
|
||
model: collage.n >= 4 ? plRow.modelData.covers.slice(0, 4) : []
|
||
delegate: Image {
|
||
required property string modelData
|
||
width: (collage.width - collage.gap) / 2
|
||
height: (collage.height - collage.gap) / 2
|
||
source: root.coverMap[modelData] || ""
|
||
fillMode: Image.PreserveAspectCrop
|
||
asynchronous: true
|
||
// Per-tile scissor: a crop
|
||
// fit overflows its own
|
||
// bounds, and the
|
||
// container's clip is 20px
|
||
// wide — without this a
|
||
// wide cover bleeds into
|
||
// the neighbouring cell
|
||
// instead of being cropped
|
||
// to it (Slint's
|
||
// `image-fit: cover`
|
||
// clips per element).
|
||
clip: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
QbzIcon {
|
||
visible: !isFolder && !useCollage
|
||
name: "list-music"
|
||
width: 15
|
||
height: 15
|
||
x: Math.round((parent.width - width) / 2)
|
||
y: Math.round((parent.height - height) / 2)
|
||
tintName: (rowArea.containsMouse || isActive) ? "textPrimary" : "muted"
|
||
}
|
||
}
|
||
// Name (hidden in mini). A Row is a positioner
|
||
// with no stretch, so this width must reserve
|
||
// EVERY later cell + its spacing or the excess
|
||
// is pushed past the row's right edge and the
|
||
// `clip: true` on the Flickable eats it.
|
||
// (Sidebar.slint gets this free: its
|
||
// HorizontalLayout stretches the name cell.)
|
||
// Reserve: 8 + 24 icon slot always; for a
|
||
// FOLDER also 8 + 14 chevron, plus 8 + the
|
||
// count label while it is laid out. The old
|
||
// flat -22 ignored the count, pushing the
|
||
// chevron ~15px past the clip — which is why
|
||
// the caret vanished on exactly the folders
|
||
// that HAVE children (owner report).
|
||
Text {
|
||
visible: !root.mini
|
||
width: Math.max(0, parent.width - 32
|
||
- (isFolder
|
||
? 22 + (countLabel.visible
|
||
? countLabel.implicitWidth + 8 : 0)
|
||
: 0)
|
||
// The local mark is a sibling AFTER
|
||
// this Text, so its width has to come
|
||
// out of the name's reserve or the
|
||
// glyph lands past the row's clip.
|
||
- (modelData.isLocal === true ? 20 : 0))
|
||
height: parent.height
|
||
text: modelData.name
|
||
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
font.weight: isFolder ? theme.weightSemibold : theme.weightRegular
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
// LOCAL playlist mark. A local playlist has no
|
||
// Qobuz side at all — this is how the row says
|
||
// so, and it is the only visual difference
|
||
// between the two kinds in the list.
|
||
QbzIcon {
|
||
visible: !root.mini && modelData.isLocal === true
|
||
name: "hard-drive"
|
||
width: 12
|
||
height: 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: "muted"
|
||
}
|
||
// Folder count (hover) + chevron. The count is
|
||
// hover-FADED, not hidden — opacity does not
|
||
// free layout space, so it must stay in the
|
||
// name's reserve above at all times.
|
||
Text {
|
||
id: countLabel
|
||
visible: !root.mini && isFolder && modelData.count > 0
|
||
height: parent.height
|
||
text: modelData.count
|
||
color: theme.textMuted
|
||
font.pixelSize: 11
|
||
verticalAlignment: Text.AlignVCenter
|
||
opacity: rowArea.containsMouse ? 1.0 : 0.0
|
||
}
|
||
QbzIcon {
|
||
visible: !root.mini && isFolder
|
||
name: modelData.expanded ? "chevron-down" : "chevron-right"
|
||
width: 14
|
||
height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: rowArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
}
|
||
// Active-row redundant shape cue: 3px accent bar.
|
||
Rectangle {
|
||
visible: isActive
|
||
x: 0
|
||
width: 3
|
||
height: parent.height - 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
radius: 1.5
|
||
color: theme.accent
|
||
}
|
||
MouseArea {
|
||
id: rowArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
// Right-press anywhere on the row opens its
|
||
// context menu (Sidebar.slint:389-397). The old
|
||
// hover "..." button was removed upstream
|
||
// because the scrollbar overlapped it and ate
|
||
// the click — do not reintroduce it.
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
// Mini: the row is an icon, so hovering names
|
||
// it (Sidebar.slint:342-349). No-op while open.
|
||
onContainsMouseChanged: {
|
||
if (containsMouse) root.showMiniTip(plRow, plRow.modelData)
|
||
else root.hideMiniTip(plRow.modelData.id)
|
||
}
|
||
onClicked: function (mouse) {
|
||
// Slint drops the bubble on click so it
|
||
// does not linger behind the flyout.
|
||
root.hideMiniTip(plRow.modelData.id)
|
||
if (mouse.button === Qt.RightButton) {
|
||
// SUPPRESSED ON THE RAIL. Slint anchors
|
||
// this menu at `row.width - 210px`,
|
||
// which on a 34px rail row is deeply
|
||
// negative; Qt's viewport clamp would
|
||
// silently drop it at x=8, which is not
|
||
// the reference's placement, just the
|
||
// clamp.
|
||
if (root.mini)
|
||
return
|
||
var lx = plRow.width - rowMenu.menuWidth
|
||
if (isFolder)
|
||
rowMenu.openForFolder(plRow.modelData, plRow, lx, 28)
|
||
else
|
||
rowMenu.openForPlaylist(plRow.modelData, plRow, lx, 28)
|
||
return
|
||
}
|
||
if (isFolder) {
|
||
// Mini: the nested rows are hidden, so
|
||
// a toggle would change NOTHING on
|
||
// screen — the contents open in a
|
||
// flyout to the right instead
|
||
// (Sidebar.slint:355-377).
|
||
if (root.mini) root.openFolderFlyout(plRow, plRow.modelData)
|
||
else QbzShell.sidebarToggleFolder(modelData.id)
|
||
} else {
|
||
root.activePlaylistId = modelData.id
|
||
QbzBridge.openPlaylist(modelData.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Empty state. NOTE: Slint does NOT gate this on mini
|
||
// (Sidebar.slint:1077-1082) and word-wraps the sentence
|
||
// inside the 48px track; keeping the gate here is a
|
||
// deliberate, owner-visible divergence.
|
||
Text {
|
||
visible: !root.mini && root.entries.length === 0 && !QbzSession.offline
|
||
width: plColumn.width
|
||
text: QbzSession.tr("No playlists yet.", QbzSession.trRev)
|
||
color: theme.textMuted
|
||
font.pixelSize: 12
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
}
|
||
}
|
||
|
||
// List scrollbar (Sidebar.slint:1090-1137). The shared
|
||
// QbzScrollBar — the port's ListScrollbar replica — rather than a
|
||
// third scrollbar implementation. Two accepted deviations from
|
||
// the sidebar's bespoke Slint one: it reveals on SCROLL (900ms
|
||
// auto-hide) instead of on row-hover, and its thumb is the
|
||
// standard 8/10px instead of the sidebar's thinner 3-6px.
|
||
// Placed in the sidebar's right PADDING (negative margin), like
|
||
// Slint's `x: parent.width - width + (mini ? 8 : Spacing.md)`, so
|
||
// the gutter never overlays the rows and cannot eat their clicks.
|
||
//
|
||
// MINI CAVEAT after the optical re-centring above: the right pad is
|
||
// 4px now, so an 8px gutter pinned to the rail's edge shares its
|
||
// left 4px with the rows. That is fine to LOOK at (an 8px-wide,
|
||
// auto-hiding indicator whose thumb grazes the pill's rounded right
|
||
// edge) but not to CLICK through, so the mini instance is
|
||
// `enabled: false`: indicator only, no drag / click-to-position.
|
||
// Nothing is lost — the wheel scrolls the rail, and a 3px-of-travel
|
||
// drag inside a 50px rail was never a real affordance. The open
|
||
// sidebar keeps the full interactive bar.
|
||
QbzScrollBar {
|
||
target: plFlick
|
||
enabled: !root.mini
|
||
width: root.mini ? 8 : 10
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: -(root.mini ? root.miniPadRight : theme.spacingMd)
|
||
anchors.top: parent.top
|
||
anchors.bottom: parent.bottom
|
||
}
|
||
}
|
||
}
|
||
}
|