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
No files matched your search
@@ -105,6 +105,9 @@ fn main() {
|
||||
"qml/views/PlaylistView.qml",
|
||||
"qml/views/SearchView.qml",
|
||||
"qml/views/SectionRail.qml",
|
||||
"qml/controls/DiscoverConfigModal.qml",
|
||||
"qml/controls/GenreFilterPopup.qml",
|
||||
"qml/shell/NavFlyout.qml",
|
||||
"qml/controls/HeaderGradient.qml",
|
||||
"qml/controls/QbzJumpNavBar.qml",
|
||||
"qml/shell/LyricsControlsFlyout.qml",
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#4285f4">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#4285f4">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 692 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#000000">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#000000">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 692 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#ef4444">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#888888">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#888888">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 692 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#ffffff">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#ffffff">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 692 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#cccccc">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#cccccc">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 692 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 306 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg" fill="#fbbf24">
|
||||
|
||||
<defs id="defs2797"/>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#fbbf24">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 692 B |
@@ -0,0 +1,307 @@
|
||||
// DiscoverConfigModal — QML port of
|
||||
// crates/qbz-ui/ui/discover/DiscoverConfigModal.slint (the per-tab
|
||||
// "Customize" modal opened by the Discover toolbar gear): reorder +
|
||||
// show/hide the active tab's rails, with a perf-warning banner, an
|
||||
// enabled/total count and a Reset-to-defaults footer.
|
||||
//
|
||||
// No Save button — every toggle / move / reset persists live to
|
||||
// discover_prefs.db and re-renders the three tabs from the cached section
|
||||
// data (src/discover_config_qt.rs), so the list stays authoritative.
|
||||
//
|
||||
// Mount as the LAST child of the view root (declaration order IS z-order)
|
||||
// with `anchors.fill: parent`; ADR-009's ">= 3000" is satisfied structurally
|
||||
// AND by the explicit z below.
|
||||
//
|
||||
// POC-NOTE: the Slint modal's Recommendations arm (explainer + cache window
|
||||
// + "Refresh now") drives ExternalRecoState, and the external reco engine is
|
||||
// not ported — the toolbar gear is DISABLED on that tab instead (HomeView).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// "home" | "editorPicks" | "forYou" — the tab whose sections are edited.
|
||||
property string tab: "home"
|
||||
readonly property bool opened: root._open
|
||||
property bool _open: false
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var doc: {
|
||||
try {
|
||||
return JSON.parse(QbzBridge.discoverConfigJson)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
// Only render rows once Rust has published THIS tab's payload.
|
||||
readonly property bool mine: (doc.tab || "") === root.tab
|
||||
readonly property var rows: root.mine ? (doc.rows || []) : []
|
||||
|
||||
function open(forTab) {
|
||||
root.tab = forTab
|
||||
QbzBridge.discoverConfigOpen(forTab)
|
||||
root._open = true
|
||||
}
|
||||
function close() {
|
||||
root._open = false
|
||||
}
|
||||
|
||||
visible: root._open
|
||||
enabled: root._open
|
||||
z: 3100
|
||||
|
||||
// Scrim — declared FIRST so the panel below it in source order (and thus
|
||||
// above it in z) keeps its own presses.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#bf000000"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: panel
|
||||
width: Math.min(root.width - 80, 520)
|
||||
height: Math.min(panelCol.implicitHeight + 40, root.height * 0.78)
|
||||
x: Math.round((root.width - width) / 2)
|
||||
y: Math.round((root.height - height) / 2)
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
clip: true
|
||||
|
||||
// Swallow clicks so they never reach the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
// NOT `body`: WarningBanner below has a `body` PROPERTY, and an
|
||||
// id that shadows a child's property name is a trap waiting for
|
||||
// the next reader.
|
||||
id: panelCol
|
||||
x: 20
|
||||
y: 20
|
||||
width: parent.width - 40
|
||||
spacing: 14
|
||||
|
||||
// --- Title + close --------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 28
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Customize", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 6
|
||||
color: cfgCloseArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "x"
|
||||
width: 17
|
||||
height: 17
|
||||
tintName: cfgCloseArea.containsMouse ? "primary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: cfgCloseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Perf-warning banner (the shared WarningBanner replica) ----
|
||||
WarningBanner {
|
||||
width: parent.width
|
||||
variant: "warning"
|
||||
body: QbzSession.tr("Enabling more sections increases load time.", QbzSession.trRev)
|
||||
}
|
||||
|
||||
// --- Count line -----------------------------------------------
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzSession.tr("{} of {} enabled", QbzSession.trRev)
|
||||
.replace("{}", root.mine ? (doc.enabled || 0) : 0)
|
||||
.replace("{}", root.mine ? (doc.total || 0) : 0)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
|
||||
// --- Section list ---------------------------------------------
|
||||
// Explicit, content-derived height capped to what is left of the
|
||||
// panel; the Flickable scrolls past that.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(0, Math.min(listCol.height, root.height * 0.78 - 260))
|
||||
clip: true
|
||||
Flickable {
|
||||
id: listFlick
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: listCol.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: listCol
|
||||
width: listFlick.width
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: root.rows
|
||||
delegate: Rectangle {
|
||||
id: cfgRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: listCol.width
|
||||
height: 40
|
||||
radius: theme.radiusSm
|
||||
color: cfgRowArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
|
||||
// Whole-row tap toggles (primary affordance).
|
||||
// Declared FIRST so the checkbox and the two
|
||||
// reorder buttons below sit ON TOP of it and
|
||||
// keep their own presses.
|
||||
MouseArea {
|
||||
id: cfgRowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.discoverToggleSection(root.tab, cfgRow.modelData.id)
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 6
|
||||
spacing: 12
|
||||
|
||||
QbzCheckbox {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: cfgRow.modelData.enabled
|
||||
onToggled: QbzBridge.discoverToggleSection(root.tab, cfgRow.modelData.id)
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 18 - 28 - 28 - 3 * 12
|
||||
height: parent.height
|
||||
text: cfgRow.modelData.label
|
||||
color: cfgRow.modelData.enabled ? theme.textPrimary : theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
ReorderButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "chevron-up"
|
||||
buttonEnabled: cfgRow.index > 0
|
||||
onClicked: QbzBridge.discoverMoveSection(root.tab, cfgRow.modelData.id, -1)
|
||||
}
|
||||
ReorderButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "chevron-down"
|
||||
buttonEnabled: cfgRow.index < root.rows.length - 1
|
||||
onClicked: QbzBridge.discoverMoveSection(root.tab, cfgRow.modelData.id, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state — a tab whose sections have not been fetched yet.
|
||||
Text {
|
||||
visible: root.rows.length === 0
|
||||
width: parent.width
|
||||
height: 34
|
||||
text: QbzSession.tr("No sections to configure yet.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
// --- Footer: Reset to defaults --------------------------------
|
||||
Rectangle {
|
||||
width: resetRow.width + 24
|
||||
height: 34
|
||||
radius: theme.radiusSm
|
||||
color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Row {
|
||||
id: resetRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: "rotate-ccw"
|
||||
width: 15
|
||||
height: 15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Reset to defaults", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: resetArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.discoverResetTab(root.tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 28x28 ghost reorder button (the Slint ReorderButton): dims and drops
|
||||
// the pointer cursor at the list boundary.
|
||||
component ReorderButton: Rectangle {
|
||||
id: reorderRoot
|
||||
property string glyph: "chevron-up"
|
||||
property bool buttonEnabled: true
|
||||
signal clicked()
|
||||
|
||||
width: 28
|
||||
height: 28
|
||||
radius: theme.radiusSm
|
||||
opacity: reorderRoot.buttonEnabled ? 1.0 : 0.3
|
||||
color: (reorderRoot.buttonEnabled && reorderArea.containsMouse)
|
||||
? theme.surfaceHover : "transparent"
|
||||
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: reorderRoot.glyph
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: reorderArea
|
||||
anchors.fill: parent
|
||||
enabled: reorderRoot.buttonEnabled
|
||||
hoverEnabled: reorderRoot.buttonEnabled
|
||||
cursorShape: reorderRoot.buttonEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: reorderRoot.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// GenreFilterPopup — QML port of crates/qbz-ui/ui/discover/GenreFilterPopup.slint
|
||||
// (+ its GenreCard / GenreTreeRowItem sub-components).
|
||||
//
|
||||
// ONE shared overlay used by every surface that draws a "Filter by genre"
|
||||
// button. Mount it as the LAST child of a view root (declaration order IS
|
||||
// z-order in QML) with `anchors.fill: parent`, give it the surface's
|
||||
// `context`, and drive it from the button:
|
||||
//
|
||||
// GenreFilterPopup { id: genrePopup; anchors.fill: parent
|
||||
// context: "discover"; anchorTop: 62 }
|
||||
// ... MouseArea { onClicked: genrePopup.toggle() } // click handler: safe
|
||||
//
|
||||
// The BUTTON's badge must NOT bind to `genrePopup.selectedCount`: the popup is
|
||||
// declared last, and a creation-time binding that dereferences a
|
||||
// not-yet-created id registers no dependency and never re-evaluates. Views
|
||||
// parse QbzBridge.genreFilterJson themselves for that (see HomeView's
|
||||
// `genreCount`); `selectedCount` here is for post-creation reads.
|
||||
//
|
||||
// Data: QbzBridge.genreFilterJson (src/genre_filter_qt.rs) — chips, the
|
||||
// 3-level advanced tree, remember/advanced flags, and the PER-CONTEXT
|
||||
// selection counts + names. Actions go straight back through the QbzBridge
|
||||
// invokables; the controller persists and refreshes the right consumer
|
||||
// (Discover re-fetches the index, Library > All re-derives in JS).
|
||||
//
|
||||
// The counts are PER CONTEXT, so Discover's button and Library's button each
|
||||
// show their own badge no matter which one was opened last (the Slint pair
|
||||
// both read the shared current-context count — one of them showed the
|
||||
// other's number).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
// Self-referential directory import: QbzToggle lives next to this file and
|
||||
// the port's proven way to reach a control is the explicit directory import
|
||||
// (a missing one resolves LAZILY and dies at runtime, not at build).
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// "discover" | "library-all" — which selection this instance edits.
|
||||
property string context: "discover"
|
||||
// Card position: below the toolbar, pinned to the right edge (the Slint
|
||||
// overlay sits under the Filter-by-genre button).
|
||||
property int anchorTop: 62
|
||||
property int anchorRight: 32
|
||||
|
||||
readonly property bool opened: root._open
|
||||
property bool _open: false
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
readonly property var doc: {
|
||||
try {
|
||||
return JSON.parse(QbzBridge.genreFilterJson)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
// This surface's own selection size (button badge).
|
||||
readonly property int selectedCount: (doc.counts || {})[root.context] || 0
|
||||
// The popup body only ever shows the context it was opened for.
|
||||
readonly property bool mine: (doc.context || "") === root.context
|
||||
readonly property var chips: root.mine ? (doc.genres || []) : []
|
||||
readonly property var treeRows: root.mine ? (doc.tree || []) : []
|
||||
readonly property bool advanced: root.mine && doc.advanced === true
|
||||
readonly property bool remember: doc.remember !== false
|
||||
readonly property bool loading: root.mine && doc.loading === true
|
||||
readonly property int liveCount: root.mine ? (doc.selectedCount || 0) : 0
|
||||
|
||||
// `genreOpen` resets the controller's query, and `setAdvanced(false)`
|
||||
// clears it too — mirror that in the box so a reopened advanced view
|
||||
// never shows a stale needle over an unfiltered tree.
|
||||
onAdvancedChanged: if (!root.advanced) searchInput.text = ""
|
||||
|
||||
function open() {
|
||||
searchInput.text = ""
|
||||
QbzBridge.genreOpen(root.context)
|
||||
root._open = true
|
||||
}
|
||||
function close() {
|
||||
root._open = false
|
||||
}
|
||||
function toggle() {
|
||||
if (root._open)
|
||||
root.close()
|
||||
else
|
||||
root.open()
|
||||
}
|
||||
|
||||
visible: root._open
|
||||
enabled: root._open
|
||||
z: 3000
|
||||
|
||||
// Click-out backdrop — declared FIRST so every later sibling (the card)
|
||||
// sits above it and keeps its own presses.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
x: parent.width - width - root.anchorRight
|
||||
y: root.anchorTop
|
||||
width: 470
|
||||
height: col.implicitHeight + 32
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceMain
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
|
||||
// Swallow clicks that land on the card's own chrome so the backdrop
|
||||
// does not close it. Declared FIRST: the content below stays on top.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
id: col
|
||||
x: 16
|
||||
y: 16
|
||||
width: parent.width - 32
|
||||
spacing: 14
|
||||
|
||||
// --- Header ---------------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "secondary"
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 26
|
||||
radius: 6
|
||||
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "x"
|
||||
width: 15
|
||||
height: 15
|
||||
tintName: closeArea.containsMouse ? "primary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Options: Remember selection + Advanced view ---------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 22
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
width: (parent.width - 16) / 2
|
||||
spacing: 10
|
||||
Text {
|
||||
width: parent.width - 50
|
||||
height: 22
|
||||
text: QbzSession.tr("Remember selection", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzToggle {
|
||||
checked: root.remember
|
||||
onToggled: function (v) { QbzBridge.genreSetRemember(v) }
|
||||
}
|
||||
}
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
width: (parent.width - 16) / 2
|
||||
spacing: 10
|
||||
Text {
|
||||
width: parent.width - 50
|
||||
height: 22
|
||||
text: QbzSession.tr("Advanced view", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
QbzToggle {
|
||||
checked: root.advanced
|
||||
onToggled: function (v) { QbzBridge.genreSetAdvanced(v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Advanced view: search + 3-level tree ----------------------
|
||||
Column {
|
||||
visible: root.advanced
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 34
|
||||
radius: 6
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: searchInput.activeFocus ? theme.borderSubtle : theme.surfaceElevated
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
id: searchInput
|
||||
width: parent.width - 22
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: QbzBridge.genreSearch(text)
|
||||
Text {
|
||||
visible: searchInput.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search genres…", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 320
|
||||
Flickable {
|
||||
id: treeFlick
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: treeCol.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Column {
|
||||
id: treeCol
|
||||
width: treeFlick.width
|
||||
spacing: 1
|
||||
Repeater {
|
||||
model: root.treeRows
|
||||
delegate: Rectangle {
|
||||
id: treeRow
|
||||
required property var modelData
|
||||
width: treeCol.width
|
||||
height: 34
|
||||
radius: 6
|
||||
color: rowArea.containsMouse || arrowArea.containsMouse
|
||||
? theme.surfaceHover : "transparent"
|
||||
|
||||
// Row click toggles selection. Declared
|
||||
// FIRST so the chevron's MouseArea below
|
||||
// sits ON TOP of it — otherwise the row
|
||||
// swallows the chevron press and the
|
||||
// sub-genres never expand (Slint #545,
|
||||
// same z-order rule).
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.genreToggle(treeRow.modelData.id)
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6 + treeRow.modelData.level * 18
|
||||
anchors.rightMargin: 10
|
||||
spacing: 6
|
||||
|
||||
Item {
|
||||
width: 20
|
||||
height: parent.height
|
||||
QbzIcon {
|
||||
visible: treeRow.modelData.hasChildren
|
||||
anchors.centerIn: parent
|
||||
name: treeRow.modelData.expanded ? "chevron-down" : "chevron-right"
|
||||
width: 13
|
||||
height: 13
|
||||
tintName: "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: arrowArea
|
||||
anchors.fill: parent
|
||||
// Disabled on leaf rows so the
|
||||
// press falls through to the row
|
||||
// (an enabled MouseArea eats it
|
||||
// even with a no-op handler —
|
||||
// 20px dead zone otherwise).
|
||||
enabled: treeRow.modelData.hasChildren
|
||||
hoverEnabled: treeRow.modelData.hasChildren
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.genreToggleExpand(treeRow.modelData.id)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 4
|
||||
color: treeRow.modelData.selected ? theme.accent : "transparent"
|
||||
border.width: treeRow.modelData.selected ? 0 : 2
|
||||
border.color: theme.borderMuted
|
||||
QbzIcon {
|
||||
visible: treeRow.modelData.selected
|
||||
anchors.centerIn: parent
|
||||
name: "check"
|
||||
width: 11
|
||||
height: 11
|
||||
tintName: "primary"
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
// Row: [20 arrow][16 box][name][count],
|
||||
// spacing 6 — the count and ITS
|
||||
// spacing only exist when shown
|
||||
// (an invisible Text still has a
|
||||
// width, so it cannot be
|
||||
// subtracted unconditionally).
|
||||
width: parent.width - 26 - 22
|
||||
- (countLabel.visible ? countLabel.width + 6 : 0)
|
||||
height: parent.height
|
||||
text: treeRow.modelData.name
|
||||
color: treeRow.modelData.selected ? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: treeRow.modelData.level === 0
|
||||
? theme.weightMedium : theme.weightRegular
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
id: countLabel
|
||||
visible: (treeRow.modelData.count || 0) > 0
|
||||
height: parent.height
|
||||
text: treeRow.modelData.count
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple grid: parent genres as chips -----------------------
|
||||
Grid {
|
||||
id: chipGrid
|
||||
visible: !root.advanced && root.chips.length > 0
|
||||
width: parent.width
|
||||
columns: 3
|
||||
columnSpacing: 8
|
||||
rowSpacing: 8
|
||||
readonly property int cellWidth: Math.floor((width - 2 * columnSpacing) / columns)
|
||||
|
||||
Repeater {
|
||||
model: root.chips
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
width: chipGrid.cellWidth
|
||||
height: 38
|
||||
radius: 6
|
||||
color: chip.modelData.selected ? theme.accent
|
||||
: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 8
|
||||
Text {
|
||||
width: parent.width - 24
|
||||
height: parent.height
|
||||
text: chip.modelData.name
|
||||
color: chip.modelData.selected ? theme.accentText : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: chip.modelData.selected ? theme.weightMedium : theme.weightRegular
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 8
|
||||
color: chip.modelData.selected ? theme.accentText : "transparent"
|
||||
border.width: chip.modelData.selected ? 0 : 2
|
||||
border.color: theme.borderMuted
|
||||
QbzIcon {
|
||||
visible: chip.modelData.selected
|
||||
anchors.centerIn: parent
|
||||
name: "check"
|
||||
width: 11
|
||||
height: 11
|
||||
tintName: "accent"
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: chipArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.genreToggle(chip.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty/loading state for the simple grid — the parent list loads
|
||||
// on first open, so the card would otherwise flash a bare header.
|
||||
Text {
|
||||
visible: !root.advanced && root.chips.length === 0
|
||||
width: parent.width
|
||||
height: 38
|
||||
// `!mine` = the context switch has been sent but Rust's
|
||||
// republish has not landed yet (one event-loop hop), which
|
||||
// reads as loading, not as "nothing here".
|
||||
text: (root.loading || !root.mine)
|
||||
? QbzSession.tr("Loading…", QbzSession.trRev)
|
||||
: QbzSession.tr("No genres available.", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
// --- Footer: Clear filter -------------------------------------
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 34
|
||||
radius: 6
|
||||
opacity: root.liveCount > 0 ? 1.0 : 0.5
|
||||
color: clearArea.containsMouse && root.liveCount > 0
|
||||
? theme.surfaceHover : theme.surfaceElevated
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Clear filter", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.liveCount > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (root.liveCount > 0) QbzBridge.genreClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,35 @@
|
||||
// missing. `tint` is the header_tint hex the Rust side now publishes in the
|
||||
// page document (album_qt.rs / artist_qt.rs `headerColor`).
|
||||
//
|
||||
// Two stacked bands, read off the .slint:
|
||||
// Bands, read off the .slint:
|
||||
// band 1 linear-gradient(180deg, tint 0%, tint 16%, transparent 100%)
|
||||
// band 2 linear-gradient(180deg, transparent 0%, transparent 82%,
|
||||
// band 4 linear-gradient(180deg, transparent 0%, transparent 82%,
|
||||
// surface-main 100%)
|
||||
// Band 2 lands the fade EXACTLY on the header/content boundary whatever
|
||||
// Band 4 lands the fade EXACTLY on the header/content boundary whatever
|
||||
// height the header resolved to, which is why the host binds `height` to the
|
||||
// divider's y rather than to a constant.
|
||||
//
|
||||
// --- LIGHT-THEME CONTRAST FLOOR (bands 2 + 3) ---------------------------
|
||||
// The header text is WHITE whenever this band is on — AlbumPageView.slint
|
||||
// :169-172 pins `hdr-strong: #ffffff` / `hdr-body: #ffffff@88%` the moment
|
||||
// `header-light` is true, on EVERY theme. That is only safe because the
|
||||
// backdrop the .slint normally paints is route A, and route A is OPAQUE
|
||||
// DARK: ImmersiveAtmosphere.slint:18 fills `#0a0a0b` under the artwork
|
||||
// copies, then :76-79 lay a flat `#000000 @ dim` sheet (the album/artist
|
||||
// headers pass `dim: 0.24`) and a `linear-gradient(180deg, #00000066 0%,
|
||||
// transparent 35%, #00000080 100%)` vignette over the whole band. White text
|
||||
// and the overlay-palette circles sit on that, not on the page colour.
|
||||
//
|
||||
// Route B alone does NOT reproduce that: its tint alpha decays from 1.0 at
|
||||
// 16% to 0.0 at 100%, so below roughly half the band the backdrop IS the
|
||||
// page. On a dark theme that is invisible (white on dark either way); on a
|
||||
// LIGHT theme the description, the meta line and the whole CircleAction row
|
||||
// — which live in the bottom third of the header — were white on white.
|
||||
// Route A's two constant sheets are therefore replicated verbatim here as
|
||||
// bands 2 and 3, and band 1 holds the tint solid until the 82% mark where
|
||||
// band 4 takes over, matching route A's opaque coverage instead of route
|
||||
// B's early decay. Same numbers, same order as ImmersiveAtmosphere.
|
||||
//
|
||||
// Mount it as the FIRST child of the page Flickable (so it scrolls with the
|
||||
// content, as the Slint's does) at x:0 with the viewport width — the band is
|
||||
// deliberately full-bleed: the page's own 32px padding must not clip it, or
|
||||
@@ -44,6 +65,10 @@ Item {
|
||||
/// (AlbumPageView.slint:168). The dynamic background and this atmosphere
|
||||
/// clash, so only one of them is ever on.
|
||||
property bool active: false
|
||||
/// ImmersiveAtmosphere's flat black sheet — the album and artist headers
|
||||
/// both mount it with `dim: 0.24` (AlbumPageView.slint:230,
|
||||
/// ArtistPageView.slint:222).
|
||||
property real dim: 0.24
|
||||
|
||||
// `visible` gates the paint, but bindings still evaluate underneath it —
|
||||
// so the stops read this, never the raw string: coercing "" to a colour
|
||||
@@ -54,15 +79,35 @@ Item {
|
||||
// Never eat clicks meant for the header controls painted on top.
|
||||
enabled: false
|
||||
|
||||
// Band 1 — the artwork tint. Solid down to the 82% mark (route A's image
|
||||
// field is opaque over exactly that span); the last 18% is band 4's job.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: root.tintColor }
|
||||
GradientStop { position: 0.16; color: root.tintColor }
|
||||
GradientStop { position: 0.82; color: root.tintColor }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
}
|
||||
|
||||
// Band 2 — ImmersiveAtmosphere.slint:77-79, `#000000.with-alpha(dim)`.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Qt.rgba(0, 0, 0, root.dim)
|
||||
}
|
||||
|
||||
// Band 3 — ImmersiveAtmosphere.slint:80-82 vignette, verbatim stops.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#66000000" }
|
||||
GradientStop { position: 0.35; color: "transparent" }
|
||||
GradientStop { position: 1.0; color: "#80000000" }
|
||||
}
|
||||
}
|
||||
|
||||
// Band 4 — the blend that must COMPLETE on the header/content divider.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
|
||||
@@ -20,6 +20,26 @@
|
||||
// #ffffffcc ring, white glyph.
|
||||
// NOTE: the card hover-overlay circles are still the separate
|
||||
// CardOverlayButton.qml — same palette, different sizing contract.
|
||||
//
|
||||
// --- LIGHT-THEME LEGIBILITY (Slint 2.0.2, "CircleAction buttons legible on
|
||||
// --- light themes" — docs/release-2.0.2/CHANGELOG.md:213) ----------------
|
||||
// The `on-surface` arm IS that fix: CircleAction.slint:30-37 exists exactly
|
||||
// because "the default light-on-dark palette (white fills/rings/icons)
|
||||
// vanishes on light themes' plain surface". Its secondary glyph is
|
||||
// `Theme.text-primary` (CircleAction.slint:73) — the theme's max-contrast
|
||||
// colour, DARK on a light theme.
|
||||
//
|
||||
// The port could not express that, and this is where it was still broken:
|
||||
// icon tints here are PRE-BAKED SVG variants (QbzIcon.qml), and the
|
||||
// "primary" bake is a hardcoded `fill="#ffffff"` — it is NOT text-primary,
|
||||
// it is white. So the on-surface secondary (a `surface-elevated` disc, i.e.
|
||||
// near-white on a light theme) was painting a WHITE glyph on it: the exact
|
||||
// vanishing act the .slint arm was written to stop. `onSurfaceTint` below
|
||||
// resolves text-primary to the only baked dark variant ("black") whenever
|
||||
// the active theme is light — theme.isDark is Rust-published from the
|
||||
// surface-main luminance (theme_qt.rs:221), the same luminance test Slint's
|
||||
// `Theme.is-dark` uses. The OVERLAY arm keeps its literals: it sits on the
|
||||
// artwork band, which is dark under every theme.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -38,6 +58,11 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
/// Slint's `Theme.text-primary` glyph tint, expressed in baked variants:
|
||||
/// the "primary" bake is a literal #ffffff, so a light theme has to take
|
||||
/// the "black" bake or the glyph paints white-on-near-white.
|
||||
readonly property string onSurfaceTint: theme.isDark ? "primary" : "black"
|
||||
|
||||
width: primary ? 44 : 32
|
||||
height: primary ? 44 : 32
|
||||
radius: width / 2
|
||||
@@ -56,10 +81,18 @@ Rectangle {
|
||||
width: root.primary ? 19 : 15
|
||||
height: root.primary ? 19 : 15
|
||||
anchors.centerIn: parent
|
||||
// active wins in both arms; then: overlay primary = black glyph on
|
||||
// the white disc, everything else = the light/primary glyph.
|
||||
tintName: root.active ? "accent"
|
||||
: (root.overlay && root.primary ? "black" : "primary")
|
||||
// CircleAction.slint:70-74, verbatim:
|
||||
// active -> Theme.accent
|
||||
// on-surface + primary -> #ffffff (glyph on the accent disc)
|
||||
// on-surface + secondary -> Theme.text-primary <- the light-theme
|
||||
// case; "primary" would be white here
|
||||
// overlay + primary -> #000000 (glyph on the white disc)
|
||||
// overlay + secondary -> #ffffff
|
||||
tintName: root.active
|
||||
? "accent"
|
||||
: (root.overlay
|
||||
? (root.primary ? "black" : "primary")
|
||||
: (root.primary ? "primary" : root.onSurfaceTint))
|
||||
}
|
||||
MouseArea {
|
||||
id: cbArea
|
||||
|
||||
@@ -5,12 +5,24 @@
|
||||
// Arms: `btnSize` (32 default; 30 compact panels, 36 header), `iconSize`,
|
||||
// `active` (accent glyph), `activeBackground` (HeaderBar: elevated fill
|
||||
// when active), `btnEnabled` (dims 0.3 + muted glyph, disarms).
|
||||
//
|
||||
// LIGHT-THEME CORRECTNESS: the glyph tints are PRE-BAKED SVG colours
|
||||
// (assets/icons/<tint>/) — "primary" is literally #ffffff and "secondary"
|
||||
// #cccccc, i.e. the DARK theme's text-primary / text-secondary frozen into
|
||||
// the asset. On a light theme those render near-white on a light surface and
|
||||
// the whole button set disappears, which is the reported bug. The bake is
|
||||
// therefore chosen from the LIVE token's lightness instead of being
|
||||
// hardcoded: bright token -> the light bake, dark token -> the dark bake.
|
||||
// TODO(glue): the durable home for this is a QbzTheme.tintFor(token) helper
|
||||
// (or real runtime tinting in QbzIcon) — see the report; it is duplicated in
|
||||
// FavToggle / TransportControls / SongCard until then.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: btn
|
||||
property string name: ""
|
||||
property int btnSize: 32
|
||||
property int iconSize: 16
|
||||
@@ -21,6 +33,13 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Bake that actually matches Theme.text-primary / text-secondary in the
|
||||
// ACTIVE theme (BarControls.slint IconButton: hover -> text-primary,
|
||||
// idle -> text-secondary). text-muted maps to the "muted" bake in both
|
||||
// polarities: #888888 is mid-grey and reads on light and dark alike.
|
||||
readonly property string tintStrong: theme.textPrimary.hslLightness > 0.5 ? "primary" : "black"
|
||||
readonly property string tintWeak: theme.textSecondary.hslLightness > 0.5 ? "secondary" : "muted"
|
||||
|
||||
width: btnSize
|
||||
height: btnSize
|
||||
radius: theme.radiusSm
|
||||
@@ -34,7 +53,7 @@ Rectangle {
|
||||
anchors.centerIn: parent
|
||||
tintName: !parent.btnEnabled ? "muted"
|
||||
: parent.active ? "accent"
|
||||
: biArea.containsMouse ? "primary" : "secondary"
|
||||
: biArea.containsMouse ? btn.tintStrong : btn.tintWeak
|
||||
}
|
||||
MouseArea {
|
||||
id: biArea
|
||||
|
||||
@@ -118,12 +118,42 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// --- Polarity-baked tokens for the play cell (#638) -------------------
|
||||
// `theme.alphaTier(pct)` IS Slint's `Theme.alpha-N`: one ramp per theme,
|
||||
// white-based on dark, black-based on light. The `.length` guard covers
|
||||
// the pre-publish frame QbzTheme documents (its baked fallback carries no
|
||||
// ramp) — without it the disc would render fill-less AND ring-less, which
|
||||
// is the exact failure this block exists to prevent. The literals are the
|
||||
// Slint dark values and their light-polarity mirrors.
|
||||
readonly property color playCellFill: theme.alphaTiers.length > 0
|
||||
? theme.alphaTier(10) : (theme.isDark ? "#1affffff" : "#1a000000")
|
||||
readonly property color playCellRing: theme.alphaTiers.length > 0
|
||||
? theme.alphaTier(15) : (theme.isDark ? "#26ffffff" : "#26000000")
|
||||
/// Slint's `Theme.text-primary` icon tint in baked-variant terms: the
|
||||
/// "primary" bake is a literal #ffffff (QbzIcon.qml), so a light theme
|
||||
/// has to take "black" or the glyph is white-on-white. Also drives the
|
||||
/// hover tints of the row's trailing controls, which had the same hole.
|
||||
readonly property string playGlyphTint: theme.isDark ? "primary" : "black"
|
||||
/// TrackRow.slint:123-125 — the row hover is `Theme.alpha-8`, not
|
||||
/// surface-hover, and the .slint says why: "Hover uses the polarity-baked
|
||||
/// alpha ramp so the hover state is visible on light themes too". The
|
||||
/// zebra stripe stays the literal, per the same comment ("Zebra kept
|
||||
/// as-is to avoid a visible dark-theme stripe shift").
|
||||
readonly property color rowHoverBg: theme.alphaTiers.length > 0
|
||||
? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000")
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: 50
|
||||
radius: 8
|
||||
color: hovered ? theme.surfaceHover : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
|
||||
color: hovered ? rowHoverBg : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
|
||||
|
||||
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse
|
||||
// `playArea` is in here for the reason TrackPlayCell.slint:76-79 spells
|
||||
// out: the cell owns a hover-enabled area of its own, and "Slint's
|
||||
// has-hover does not propagate to ancestor TouchAreas" — Qt's does not
|
||||
// either, so without this the row (and the play circle with it) would go
|
||||
// un-hovered exactly while the pointer sits on the play button.
|
||||
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse
|
||||
|| moreArea.containsMouse || playArea.containsMouse
|
||||
readonly property bool isActive: QbzPlayer.npTrackId === (item.id || "")
|
||||
readonly property int cellsRight: 70 + 92 + (showFavorite ? 28 : 0) + (showDownload ? 28 : 0) + (showMenu ? 32 : 0)
|
||||
readonly property int cellsLeft: 32 + (showArtwork ? 36 : 0) + (showAlbum ? 220 : 0)
|
||||
@@ -147,33 +177,90 @@ Rectangle {
|
||||
anchors.rightMargin: 12
|
||||
spacing: 14
|
||||
|
||||
// Number cell — swaps to play on hover (pause when active+playing).
|
||||
// Number cell — the number-variant of primitives/TrackPlayCell.slint
|
||||
// (:172-232), the play affordance shared by every track row.
|
||||
//
|
||||
// --- LIGHT-THEME LEGIBILITY (Slint 2.0.2 / #638, "Track-row play
|
||||
// --- glyph … legible on light themes") ---------------------------
|
||||
// TrackPlayCell.slint:201-207 states the defect and the fix in its
|
||||
// own words: "Circular backing so the play/pause glyph is legible on
|
||||
// EVERY theme (the old bare #ffffff glyph vanished on light themes —
|
||||
// white-on-white, no scrim like the artwork variant has). Fill/border/
|
||||
// shadow use the polarity-baked alpha ramp (black-alpha on light,
|
||||
// white-alpha on dark) and the glyph uses text-primary."
|
||||
//
|
||||
// The port had the pre-fix shape: a hardcoded `#3dffffff` disc, no
|
||||
// ring, and the "primary" icon bake — which is a literal #ffffff, not
|
||||
// text-primary (QbzIcon.qml bakes tints into the SVG). Both halves are
|
||||
// now the polarity-baked ramp: theme.alphaTier() is white-based on
|
||||
// dark themes and black-based on light (qbz-theme colors.rs:125-129,
|
||||
// republished per theme by theme_qt.rs), and `playGlyphTint` resolves
|
||||
// text-primary to the only baked dark variant when the theme is light.
|
||||
//
|
||||
// Numbers off the .slint: 24px disc, 1.5px ring ("a 1px stroked circle
|
||||
// aliases badly in femtovg", :215), 16px glyph.
|
||||
// The accent-ring arm (:208-229) is the playing row: transparent fill,
|
||||
// accent circumference, accent glyph.
|
||||
// POC-NOTE: the .slint also drops a 6px `card-shadow` under the disc.
|
||||
// Qt's DropShadow is a Qt5Compat graphical effect and renders NOTHING
|
||||
// on this port's software path (the finding that killed ColorOverlay
|
||||
// in QbzIcon), so the shadow is dropped rather than faked; the ring
|
||||
// carries the separation on its own.
|
||||
Item {
|
||||
id: playCell
|
||||
width: 32
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// `show-overlay: row-hovered || is-active` (TrackPlayCell.slint
|
||||
// :93) — the playing row keeps the affordance at rest, which is
|
||||
// what the accent ring is FOR. The port showed it on hover only,
|
||||
// so the ring never appeared.
|
||||
readonly property bool showOverlay: root.hovered || root.isActive
|
||||
// `accent-ring` (:108): the playing row, static form.
|
||||
readonly property bool accentRing: root.isActive && QbzPlayer.npPlaying
|
||||
Text {
|
||||
visible: !trArea.containsMouse
|
||||
visible: !playCell.showOverlay
|
||||
anchors.centerIn: parent
|
||||
text: root.number
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Rectangle {
|
||||
visible: trArea.containsMouse
|
||||
visible: playCell.showOverlay
|
||||
anchors.centerIn: parent
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: root.isActive && QbzPlayer.npPlaying ? "transparent" : "#3dffffff"
|
||||
border.width: root.isActive && QbzPlayer.npPlaying ? 1.5 : 0
|
||||
border.color: theme.accent
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 12
|
||||
color: playCell.accentRing ? "transparent" : root.playCellFill
|
||||
border.width: 1.5
|
||||
border.color: playCell.accentRing ? theme.accent : root.playCellRing
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: root.isActive && QbzPlayer.npPlaying ? "pause" : "play-fill"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: root.isActive && QbzPlayer.npPlaying ? "accent" : "primary"
|
||||
// `show-pause` (:101-104): PAUSE only while the playing
|
||||
// row is hovered — at rest the playing row shows PLAY,
|
||||
// "an affordance, not a state badge: the accent ring +
|
||||
// the row's edge mark carry the state".
|
||||
name: playCell.accentRing && root.hovered ? "pause" : "play-fill"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: playCell.accentRing ? "accent" : root.playGlyphTint
|
||||
}
|
||||
}
|
||||
// TrackPlayCell.slint:234-243 — the cell is its OWN click target:
|
||||
// a non-current cell plays the track, the current cell toggles
|
||||
// play/pause. Without it the circle was a control that rendered
|
||||
// and did nothing on the album view (clickPlays:false there, so
|
||||
// the press fell through to the row body, which only reacts to a
|
||||
// double-click). Declared LAST so it sits ABOVE the glyph, and it
|
||||
// is above the row-body area regardless (that one is pinned z:-1).
|
||||
MouseArea {
|
||||
id: playArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.isActive) QbzPlayer.togglePlay()
|
||||
else root.playRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +410,11 @@ Rectangle {
|
||||
name: root.item.isFavorite ? "heart-filled" : "heart"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: root.item.isFavorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
|
||||
// TrackRow.slint:619 — hover raises the glyph to
|
||||
// Theme.text-primary, NOT to white (same #638 hole).
|
||||
tintName: root.item.isFavorite
|
||||
? "favorite"
|
||||
: (favArea.containsMouse ? root.playGlyphTint : "muted")
|
||||
}
|
||||
MouseArea {
|
||||
id: favArea
|
||||
@@ -366,7 +457,8 @@ Rectangle {
|
||||
name: "ellipsis"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: moreArea.containsMouse ? "primary" : "muted"
|
||||
// TrackRow.slint:750 — Theme.text-primary on hover.
|
||||
tintName: moreArea.containsMouse ? root.playGlyphTint : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: moreArea
|
||||
@@ -493,6 +585,12 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: trArea
|
||||
anchors.fill: parent
|
||||
// BEHIND the row's own controls. Declaration order is z-order in QML,
|
||||
// and this fills the whole row from the LAST declaration — so it sat on
|
||||
// top of the heart / cloud / menu buttons and swallowed their presses.
|
||||
// propagateComposedEvents does not save them: `pressed` is not a
|
||||
// composed event, so only the row's own double-click still worked.
|
||||
z: -1
|
||||
hoverEnabled: true
|
||||
propagateComposedEvents: true
|
||||
// Right press opens the SAME menu as ⋯ (rowMenu), at the pointer.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,28 @@
|
||||
// secondary, muted, accent, warning} (script-generated from the originals,
|
||||
// also rewriting fill="currentColor"). `tintName` switches variants
|
||||
// dynamically (hover = secondary -> primary), served from qrc.
|
||||
//
|
||||
// TWO WAYS A BAKE SILENTLY LIES — both have already shipped here:
|
||||
//
|
||||
// 1. NO VARIANT ON DISK -> the Image resolves to nothing and the glyph is
|
||||
// simply ABSENT. Nothing is logged. Every `name` an
|
||||
// app site can ask for must exist in every `tintName` that site can ask
|
||||
// for; `favorite/` and `amber/` are deliberately partial (favorite =
|
||||
// heart/heart-filled/link/trash-2..., amber = monitor-speaker only), so
|
||||
// only widen a caller's tint set after checking the dir.
|
||||
// 2. THE BAKE RAN BUT RECOLOURED NOTHING -> the glyph renders in the SVG's
|
||||
// OWN colour in every tint, which for a Feather-style file with no
|
||||
// paint attribute means BLACK (SVG's initial `fill` is black, and
|
||||
// `currentColor` with no inherited `color` resolves to black too). The
|
||||
// original baker only rewrote `stroke="..."` / `fill="currentColor"` on
|
||||
// the ROOT element, so three icons whose colour lives elsewhere came out
|
||||
// byte-identical in all seven dirs: element-connect (fill-based, no fill
|
||||
// attribute at all), home-gear (same, plus a `fill="none"` backdrop
|
||||
// path that must stay none) and copy (`stroke="currentColor"`).
|
||||
// Re-baked by putting the tint on the ROOT element and letting the
|
||||
// children inherit it — the convention play-fill/qbz-symbolic already
|
||||
// used. CHECK FOR THIS with a content hash: if
|
||||
// primary/<name>.svg == black/<name>.svg the icon is not tinted at all.
|
||||
|
||||
import QtQuick
|
||||
|
||||
|
||||
@@ -829,9 +829,28 @@ Rectangle {
|
||||
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 80 - 80 - 28 - 28 - 32 - 5 * 16 - 24; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter; elide: Text.ElideRight }
|
||||
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
|
||||
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
|
||||
Item { width: 28; height: 1; QbzIcon { name: "heart"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
|
||||
Item { width: 28; height: 1; QbzIcon { name: "cloud-download"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
|
||||
Item { width: 32; height: 1 }
|
||||
// Heart / offline-cache column heads. AlbumPageView
|
||||
// .slint:854-877 declares these as `Rectangle { width:
|
||||
// 28px; }` with NO height — a box-layout child with no
|
||||
// fixed cross-axis size is STRETCHED to the row, and
|
||||
// the glyph is then centred inside it (`y: (parent
|
||||
// .height - self.height) / 2`). The port gave them
|
||||
// `height: 1`, so `centerIn` centred the glyph on a
|
||||
// 1px item pinned at the top of the 40px band: the two
|
||||
// icons floated above the text labels, which do centre.
|
||||
// Fill the band and centre, exactly as the .slint does.
|
||||
Item {
|
||||
width: 28
|
||||
height: parent.height
|
||||
QbzIcon { name: "heart"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" }
|
||||
}
|
||||
Item {
|
||||
width: 28
|
||||
height: parent.height
|
||||
QbzIcon { name: "cloud-download"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" }
|
||||
}
|
||||
// Aligns with the per-row more-button slot (.slint:878).
|
||||
Item { width: 32; height: parent.height }
|
||||
}
|
||||
|
||||
// Rows (with Disc / work headers).
|
||||
|
||||
@@ -132,6 +132,19 @@ Rectangle {
|
||||
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
|
||||
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
|
||||
readonly property bool hdrOverlay: headerLight
|
||||
/// Slint's `Theme.text-primary` as an ICON tint. Icon tints in this port
|
||||
/// are pre-baked SVG variants (QbzIcon.qml) and the "primary" bake is a
|
||||
/// literal `fill="#ffffff"` — it is not the theme token. Every hover that
|
||||
/// raises a muted glyph to text-primary therefore has to pick the "black"
|
||||
/// bake on a light theme, or the glyph disappears at the moment it is
|
||||
/// supposed to light up. Same #638 class as the track-row play glyph.
|
||||
readonly property string tintOnSurface: theme.isDark ? "primary" : "black"
|
||||
/// TrackRow.slint:123-125 — the row hover uses the polarity-baked alpha
|
||||
/// ramp "so the hover state is visible on light themes too (the old
|
||||
/// #ffffff16 was invisible white-on-white there)". The zebra stripe is
|
||||
/// deliberately left as the literal, per the same comment.
|
||||
readonly property color rowHoverBg: theme.alphaTiers.length > 0
|
||||
? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000")
|
||||
property bool topTracksExpanded: false
|
||||
property bool appearsOnExpanded: false
|
||||
property bool otherExpanded: false
|
||||
@@ -248,12 +261,33 @@ Rectangle {
|
||||
// pager is QbzArtist's. Retargeting a mixed block wholesale would
|
||||
// silently orphan the other half — QML resolves handlers lazily, so the
|
||||
// discography would just stop loading with nothing in the log.
|
||||
// Covers arrive ONE AT A TIME, and on a warm cache `sidebar_artwork_window`
|
||||
// emits the whole disk-hit set in a single synchronous loop (main.rs:391).
|
||||
// Rebinding `coverMap` per arrival is quadratic in the page: each arrival
|
||||
// copied the entire map and re-evaluated the cover binding of EVERY
|
||||
// mounted card, so an artist with 200 releases did ~40k binding
|
||||
// evaluations and 200 map copies during the frames the page is trying to
|
||||
// paint. Arrivals are coalesced into ONE rebind per frame — the same fix
|
||||
// LocalLibraryView carries, at O(n) instead of O(n²), with the covers
|
||||
// still appearing progressively (16ms granularity is invisible).
|
||||
property var _coverInbox: ({})
|
||||
Timer {
|
||||
id: coverFlush
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
var m = Object.assign({}, root.coverMap, root._coverInbox)
|
||||
root._coverInbox = ({})
|
||||
// A rebind needs a NEW object reference (same-ref assignment is
|
||||
// not a change in QML).
|
||||
root.coverMap = m
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
var m = root.coverMap
|
||||
m[key] = path
|
||||
root.coverMap = Object.assign({}, m)
|
||||
root._coverInbox[key] = path
|
||||
if (!coverFlush.running) coverFlush.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +373,25 @@ Rectangle {
|
||||
if (fresh.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
|
||||
}
|
||||
|
||||
// THE RULE FOR THIS FUNCTION: every section of the page that binds
|
||||
// `root.coverMap` has to contribute its urls here, or its covers are
|
||||
// never requested and the section renders empty tiles forever — nothing
|
||||
// downstream reports the omission, because a missing key is
|
||||
// indistinguishable from a cover that has not landed yet.
|
||||
//
|
||||
// Three sections were missing and each one rendered blank:
|
||||
// - Latest release (`artist.lastRelease`, the reported bug) — one card,
|
||||
// and the only cover between Popular Tracks and the release grids;
|
||||
// - Appears On (`artist.appearsOn`) — TrackRow covers, the same
|
||||
// PopularTrackRow component the collected topTracks use, which is why
|
||||
// the omission was easy to miss;
|
||||
// - Playlists (`artist.playlists`) — the 200px rectangle covers of the
|
||||
// horizontal strip.
|
||||
// Present and collected: the header portrait, Popular Tracks, every
|
||||
// release section (including the collapsed "Other"), and the Magazine
|
||||
// stories. The Network sidebar carries NO covers (ArtistSimilar and
|
||||
// MbDiscoveryJson have no artUrl — artist_qt.rs), so it contributes none.
|
||||
// The In-library tab has its own dispatcher, dispatchLibCovers().
|
||||
function dispatchCovers() {
|
||||
var urls = []
|
||||
if (artist.artUrl) urls.push(artist.artUrl)
|
||||
@@ -346,11 +399,19 @@ Rectangle {
|
||||
// RAW lists: a filtered-out card still needs its cover for when the
|
||||
// query is cleared, and this must not re-run per keystroke.
|
||||
var rawTop = artist.topTracks || []
|
||||
var rawAppears = artist.appearsOn || []
|
||||
var rawSections = artist.releaseSections || []
|
||||
var rawPlaylists = artist.playlists || []
|
||||
for (i = 0; i < rawTop.length; i++) if (rawTop[i].artUrl) urls.push(rawTop[i].artUrl)
|
||||
for (i = 0; i < rawAppears.length; i++)
|
||||
if (rawAppears[i].artUrl) urls.push(rawAppears[i].artUrl)
|
||||
if (artist.lastRelease && artist.lastRelease.artUrl)
|
||||
urls.push(artist.lastRelease.artUrl)
|
||||
for (i = 0; i < rawSections.length; i++)
|
||||
for (j = 0; j < (rawSections[i].cards || []).length; j++)
|
||||
if (rawSections[i].cards[j].artUrl) urls.push(rawSections[i].cards[j].artUrl)
|
||||
for (i = 0; i < rawPlaylists.length; i++)
|
||||
if (rawPlaylists[i].artUrl) urls.push(rawPlaylists[i].artUrl)
|
||||
// Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
|
||||
for (i = 0; i < stories.length; i++) if (stories[i].artUrl) urls.push(stories[i].artUrl)
|
||||
dispatchArtwork(urls)
|
||||
@@ -381,7 +442,7 @@ Rectangle {
|
||||
width: parent ? parent.width : 0
|
||||
height: 50
|
||||
radius: 8
|
||||
color: hovered ? "#14ffffff" : (rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
|
||||
color: hovered ? root.rowHoverBg : (rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
|
||||
|
||||
Rectangle {
|
||||
visible: isActive
|
||||
@@ -541,7 +602,9 @@ Rectangle {
|
||||
name: parent.favorite ? "heart-filled" : "heart"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: parent.favorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
|
||||
tintName: parent.favorite
|
||||
? "favorite"
|
||||
: (favArea.containsMouse ? root.tintOnSurface : "muted")
|
||||
}
|
||||
MouseArea {
|
||||
id: favArea
|
||||
@@ -569,7 +632,7 @@ Rectangle {
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
QbzIcon { anchors.centerIn: parent; name: "ellipsis"; width: 16; height: 16; tintName: moreArea.containsMouse ? "primary" : "muted" }
|
||||
QbzIcon { anchors.centerIn: parent; name: "ellipsis"; width: 16; height: 16; tintName: moreArea.containsMouse ? root.tintOnSurface : "muted" }
|
||||
MouseArea {
|
||||
id: moreArea
|
||||
anchors.fill: parent
|
||||
@@ -613,7 +676,7 @@ Rectangle {
|
||||
width: 12
|
||||
height: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: slArea.containsMouse ? "primary" : "muted"
|
||||
tintName: slArea.containsMouse ? root.tintOnSurface : "muted"
|
||||
}
|
||||
Text {
|
||||
width: parent.width - 20
|
||||
@@ -1204,25 +1267,28 @@ Rectangle {
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Rectangle {
|
||||
width: 44
|
||||
height: 44
|
||||
radius: 22
|
||||
// ArtistPageView.slint:732-737 mounts the SHARED
|
||||
// CircleAction here — `primary: true` plus an explicit
|
||||
// `on-surface: true` with the .slint's own reason on the
|
||||
// line above it: "Plain page background (below the header
|
||||
// divider) — theme-aware variant so it reads on light
|
||||
// themes." The port hand-rolled a 44px accent disc
|
||||
// instead, which duplicated the control AND bypassed that
|
||||
// arm. `overlay` defaults false = the on-surface arm.
|
||||
QbzCircleAction {
|
||||
primary: true
|
||||
name: "play-fill"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: playTopArea.containsMouse ? theme.accentHover : theme.accent
|
||||
QbzIcon { anchors.centerIn: parent; name: "play-fill"; width: 19; height: 19; tintName: "primary" }
|
||||
MouseArea {
|
||||
id: playTopArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzPlayer.playArtistTop(false)
|
||||
}
|
||||
onClicked: QbzPlayer.playArtistTop(false)
|
||||
}
|
||||
// Multi-select has no seam on this bridge. Rendered but
|
||||
// DIMMED and click-gated (CircleAction's own `enabled`
|
||||
// treatment) rather than a live button that does nothing —
|
||||
// same call as the header's Radio / Mixtape / Info.
|
||||
QbzCircleAction {
|
||||
name: "square-check-big"
|
||||
btnEnabled: false
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// POC-NOTE: multi-select out of scope.
|
||||
}
|
||||
QbzCircleAction {
|
||||
id: topMenuBtn
|
||||
@@ -1660,7 +1726,7 @@ Rectangle {
|
||||
name: "panel-right-close"
|
||||
width: 18
|
||||
height: 18
|
||||
tintName: netCloseArea.containsMouse ? "primary" : "muted"
|
||||
tintName: netCloseArea.containsMouse ? root.tintOnSurface : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: netCloseArea
|
||||
@@ -1883,7 +1949,7 @@ Rectangle {
|
||||
name: "thumbs-down"
|
||||
width: 12
|
||||
height: 12
|
||||
tintName: dismissArea.containsMouse ? "primary" : "muted"
|
||||
tintName: dismissArea.containsMouse ? root.tintOnSurface : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: dismissArea
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
// follow the persisted Discover prefs (phase 11, discover_prefs.db).
|
||||
//
|
||||
// POC-NOTEs:
|
||||
// - The genre filter + section-configurator gear are INERT visual stubs
|
||||
// (out of scope).
|
||||
// - The genre filter (shared GenreFilterPopup, context "discover") and the
|
||||
// section-configurator gear (DiscoverConfigModal) are WIRED: the genre
|
||||
// selection feeds get_discover_index and persists to genre_filter.json; the
|
||||
// configurator persists to discover_prefs.db and re-renders the tabs from
|
||||
// the cached section data. The gear is DISABLED on the Recommendations tab
|
||||
// — its Slint arm configures the external reco engine, which is not ported.
|
||||
// - Editor's Picks / For You mount the same rails, ordered by each tab's
|
||||
// discover prefs (phase 13); Recommendations renders a placeholder — the
|
||||
// external reco engine (external_reco.rs) is not ported. The tab bar is
|
||||
@@ -51,6 +55,21 @@ Rectangle {
|
||||
readonly property var forYouSections: JSON.parse(QbzHome.forYouSectionsJson)
|
||||
property string activeTab: "home"
|
||||
|
||||
// --- shared genre filter, "discover" context -------------------------
|
||||
// Read STRAIGHT off the bridge singleton, not off genrePopup: the popup
|
||||
// is declared LAST (z-order) and a creation-time binding that
|
||||
// dereferences a not-yet-created id registers NO dependency, so it would
|
||||
// never re-evaluate. The popup instance is only touched from click
|
||||
// handlers, which run long after creation.
|
||||
readonly property var genreDoc: {
|
||||
try {
|
||||
return JSON.parse(QbzBridge.genreFilterJson)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
readonly property int genreCount: (genreDoc.counts || {})["discover"] || 0
|
||||
|
||||
// --- skeleton pulse ---------------------------------------------------
|
||||
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
|
||||
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
|
||||
@@ -630,17 +649,23 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Genre filter + configurator gear — INERT stubs (POC-NOTE).
|
||||
// Genre filter + configurator gear (HomeView.slint right-controls).
|
||||
Row {
|
||||
x: parent.width - width - 32
|
||||
y: 25 - height / 2
|
||||
height: 32
|
||||
spacing: 6
|
||||
|
||||
// GenreButton — accent fill + "N genres" while the shared
|
||||
// "discover" selection is non-empty (1:1 Slint).
|
||||
Rectangle {
|
||||
id: genreBtn
|
||||
readonly property bool active: root.genreCount > 0
|
||||
width: genreRow.width
|
||||
height: 32
|
||||
radius: 6
|
||||
color: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
color: genreBtn.active ? theme.accent
|
||||
: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Row {
|
||||
id: genreRow
|
||||
height: parent.height
|
||||
@@ -652,12 +677,18 @@ Rectangle {
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "secondary"
|
||||
tintName: genreBtn.active ? "primary" : "secondary"
|
||||
}
|
||||
Text {
|
||||
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
color: theme.textSecondary
|
||||
text: root.genreCount === 0
|
||||
? QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
: root.genreCount === 1
|
||||
? QbzSession.tr("1 genre", QbzSession.trRev)
|
||||
: QbzSession.tr("{} genres", QbzSession.trRev)
|
||||
.replace("{}", root.genreCount)
|
||||
color: genreBtn.active ? theme.accentText : theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
font.weight: genreBtn.active ? theme.weightMedium : theme.weightRegular
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
@@ -666,13 +697,23 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: genrePopup.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
// GearButton — per-tab show/hide + reorder of the Discover
|
||||
// sections. Disabled on Recommendations: its Slint arm
|
||||
// configures the external reco engine (not ported), so the
|
||||
// modal would open empty.
|
||||
Rectangle {
|
||||
id: gearBtn
|
||||
readonly property bool gearEnabled: root.activeTab !== "recommendations"
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 4
|
||||
color: gearArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
opacity: gearBtn.gearEnabled ? 1.0 : 0.35
|
||||
color: (gearBtn.gearEnabled && gearArea.containsMouse)
|
||||
? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "home-gear"
|
||||
width: 20
|
||||
@@ -683,8 +724,10 @@ Rectangle {
|
||||
MouseArea {
|
||||
id: gearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
enabled: gearBtn.gearEnabled
|
||||
hoverEnabled: gearBtn.gearEnabled
|
||||
cursorShape: gearBtn.gearEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: configModal.open(root.activeTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -834,4 +877,27 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ overlays ================================
|
||||
// Declared LAST: in QML declaration order IS z-order, so these sit above
|
||||
// the toolbar and the rails and keep their own presses (ADR-009 is also
|
||||
// satisfied explicitly by their z). Both are hidden + disabled while
|
||||
// closed, so they never eat a click meant for the view.
|
||||
|
||||
// Shared Filter-by-genre popup, "discover" context: the selection feeds
|
||||
// get_discover_index, so toggling one re-fetches the index.
|
||||
GenreFilterPopup {
|
||||
id: genrePopup
|
||||
anchors.fill: parent
|
||||
context: "discover"
|
||||
// Under the toolbar (56px + the 1px divider + a 5px gap).
|
||||
anchorTop: 62
|
||||
anchorRight: 32
|
||||
}
|
||||
|
||||
// Per-tab section configurator (the gear).
|
||||
DiscoverConfigModal {
|
||||
id: configModal
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,18 @@
|
||||
// pruned (the Slint eviction policy, QML-side).
|
||||
//
|
||||
// POC-NOTEs:
|
||||
// - Genre filter, multi-select, group modes, alpha jumps, play-all /
|
||||
// shuffle bulk actions, playlists-random, artists sidepanel, albums LIST
|
||||
// mode, per-tab persist of view/sort state: out of scope (stubs or
|
||||
// omitted; the All grid+list is the measured 1:1 focus).
|
||||
// - Multi-select, group modes, alpha jumps, play-all / shuffle bulk
|
||||
// actions, playlists-random, artists sidepanel, albums LIST mode, per-tab
|
||||
// persist of view/sort state: out of scope (stubs or omitted; the All
|
||||
// grid+list is the measured 1:1 focus).
|
||||
// - Filter by genre IS wired, on the All toolbar only — that is the single
|
||||
// place the Slint FavoritesView draws it (FavoritesView.slint:864,
|
||||
// `context: "library-all"`). It filters CLIENT-side over the feed, exactly
|
||||
// like library_all.rs::derive: an item shows when its (lowercased) genre
|
||||
// contains one of the selected genre names (+ their sub-genres), so rows
|
||||
// with no genre at all (artist / label / playlist) drop out while a genre
|
||||
// is selected. The selection lives in the shared per-context store and
|
||||
// persists to genre_filter.json.
|
||||
// - Offline: the generic OfflinePlaceholder replica mounts (the Slint
|
||||
// offline RAIL of playable cached favorites needs the offline cache —
|
||||
// not wired).
|
||||
@@ -93,6 +101,32 @@ Rectangle {
|
||||
property bool showLocal: true
|
||||
property string viewMode: "grid" // "grid" | "list"
|
||||
|
||||
// --- shared genre filter, "library-all" context ----------------------
|
||||
// Read STRAIGHT off the bridge singleton, not off libGenrePopup: the
|
||||
// popup is declared LAST (z-order) and a creation-time binding that
|
||||
// dereferences a not-yet-created id registers NO dependency, so it would
|
||||
// never re-evaluate. The popup instance is only touched from click
|
||||
// handlers, which run long after creation.
|
||||
readonly property var genreDoc: {
|
||||
try {
|
||||
return JSON.parse(QbzBridge.genreFilterJson)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
readonly property int genreCount: (genreDoc.counts || {})["library-all"] || 0
|
||||
// Selected genre NAMES (+ sub-genres), lowercased once here — the port's
|
||||
// FeedItem keeps the display casing (the Slint model lowercases at build
|
||||
// time instead, library_all.rs:540). Reading this inside visibleItems()
|
||||
// is what makes the grid/list model re-derive when a chip is toggled.
|
||||
readonly property var genreNames: {
|
||||
var src = (genreDoc.names || {})["library-all"] || []
|
||||
var out = []
|
||||
for (var i = 0; i < src.length; i++)
|
||||
out.push(String(src[i]).toLowerCase())
|
||||
return out
|
||||
}
|
||||
|
||||
// Other-tab state.
|
||||
property string tabSearch: ""
|
||||
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc
|
||||
@@ -105,6 +139,10 @@ Rectangle {
|
||||
if (activeTab === "all") {
|
||||
var needle = search.toLowerCase()
|
||||
var anyGroup = showPurchases || showFavorites || showFollowing
|
||||
// Genre gate (library_all.rs::derive): empty = no filter;
|
||||
// otherwise the item's genre must contain one of the selected
|
||||
// names, so genre-less kinds are excluded.
|
||||
var genres = root.genreNames
|
||||
for (i = 0; i < feed.length; i++) {
|
||||
var it = feed[i]
|
||||
var isLocal = it.source === "local" || it.source === "plex"
|
||||
@@ -120,6 +158,15 @@ Rectangle {
|
||||
if (needle !== ""
|
||||
&& it.title.toLowerCase().indexOf(needle) < 0
|
||||
&& it.artist.toLowerCase().indexOf(needle) < 0) continue
|
||||
if (genres.length > 0) {
|
||||
var itemGenre = (it.genre || "").toLowerCase()
|
||||
if (itemGenre === "") continue
|
||||
var genreHit = false
|
||||
for (var gi = 0; gi < genres.length; gi++) {
|
||||
if (itemGenre.indexOf(genres[gi]) >= 0) { genreHit = true; break }
|
||||
}
|
||||
if (!genreHit) continue
|
||||
}
|
||||
items.push(it)
|
||||
}
|
||||
// Canonical ascending order per field, then reverse for the
|
||||
@@ -269,6 +316,50 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter-by-genre trigger (FavoritesView.slint's FavGenreButton, sm):
|
||||
// accent fill + "N genres" while the "library-all" selection is active.
|
||||
component GenreToolButton: Rectangle {
|
||||
id: gtb
|
||||
readonly property bool active: root.genreCount > 0
|
||||
width: gtbRow.width
|
||||
height: 30
|
||||
radius: 6
|
||||
color: gtb.active ? theme.accent
|
||||
: gtbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
||||
Row {
|
||||
id: gtbRow
|
||||
height: parent.height
|
||||
leftPadding: 10
|
||||
rightPadding: 12
|
||||
spacing: 7
|
||||
QbzIcon {
|
||||
name: "list-filter"
|
||||
width: 13
|
||||
height: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: gtb.active ? "primary" : "secondary"
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.genreCount === 0
|
||||
? QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
: root.genreCount === 1
|
||||
? QbzSession.tr("1 genre", QbzSession.trRev)
|
||||
: QbzSession.tr("{} genres", QbzSession.trRev)
|
||||
.replace("{}", root.genreCount)
|
||||
color: gtb.active ? theme.accentText : theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: gtbArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: libGenrePopup.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar search box (ExpandableSearch sm replica, fixed width).
|
||||
component SearchBox: Rectangle {
|
||||
property string placeholder: ""
|
||||
@@ -694,8 +785,9 @@ Rectangle {
|
||||
ToolToggle { name: "heart"; active: root.showFavorites; onClicked: root.showFavorites = !root.showFavorites }
|
||||
ToolToggle { name: "user-plus"; active: root.showFollowing; onClicked: root.showFollowing = !root.showFollowing }
|
||||
ToolToggle { name: "hard-drive"; active: root.showLocal; onClicked: root.showLocal = !root.showLocal }
|
||||
// Genre filter — INERT stub (out of scope).
|
||||
ToolToggle { name: "list-filter"; active: false }
|
||||
// Filter by genre — shared popup, own "library-all"
|
||||
// context (FavoritesView.slint:864).
|
||||
GenreToolButton { }
|
||||
// Grid / list toggle.
|
||||
ToolToggle {
|
||||
name: root.viewMode === "list" ? "layout-grid" : "list"
|
||||
@@ -1207,4 +1299,19 @@ Rectangle {
|
||||
var last = Math.ceil((list.contentY + list.height) / 44) + 4
|
||||
queueWindowReport(first, Math.min(list.model.length - 1, last))
|
||||
}
|
||||
|
||||
// ============================ overlay =================================
|
||||
// Declared LAST: declaration order IS z-order, so the popup sits above
|
||||
// the toolbar and the grid and keeps its own presses. Hidden AND
|
||||
// disabled while closed, so it never eats a click meant for the view.
|
||||
// Its "library-all" context is independent from Discover's "discover"
|
||||
// one — each button carries its own count.
|
||||
GenreFilterPopup {
|
||||
id: libGenrePopup
|
||||
anchors.fill: parent
|
||||
context: "library-all"
|
||||
// Under the 56px toolbar, right-aligned like the Slint overlay.
|
||||
anchorTop: 62
|
||||
anchorRight: 32
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@
|
||||
// crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint (the shipping
|
||||
// behaviour; that file is the ONLY reference).
|
||||
//
|
||||
// This file owns THREE things and nothing else:
|
||||
// This file owns FOUR things and nothing else:
|
||||
// 1. state — the Slint LocalLibraryState/LibAlbumFilterState fields;
|
||||
// 2. the derived documents (search / sort / filter / grouping / A-Z), in
|
||||
// JS, because QbzLocal publishes ONE JSON document per surface (the
|
||||
// library_qt.rs transport rationale);
|
||||
// 3. the fixed chrome (title row, tab bar, toolbar, divider) and which tab
|
||||
// body is mounted.
|
||||
// body is mounted;
|
||||
// 4. the ARTWORK WINDOW REGISTRY (`_windows` + `artMap`, below). This one
|
||||
// is here — and pushes the file past the 500-line guideline — because it
|
||||
// is the one policy that CANNOT be split per surface: several cover
|
||||
// surfaces are mounted at once (rail + grid, subfolders + track rows)
|
||||
// and eviction is only correct when it is decided against all of them
|
||||
// together. Splitting it per body is exactly the bug it replaced.
|
||||
// Every body, rail, pane, popup and row lives in qml/views/local/.
|
||||
//
|
||||
// CHROME (identical to FavoritesView / LibraryView, per the Slint header):
|
||||
@@ -195,19 +201,47 @@ Rectangle {
|
||||
// The Artists detail derives from the album set (the DB aggregates
|
||||
// the contributor list per album), so make sure it is loaded.
|
||||
if (activeTab === "artists" && albums.length === 0) QbzLocal.loadTab("albums")
|
||||
// The tab that just appeared has covers to ask for and the one that
|
||||
// left has covers to let go of. Each surface answers for itself.
|
||||
artworkRefresh()
|
||||
}
|
||||
|
||||
/// "Re-report your window." Broadcast for the state changes no single
|
||||
/// surface can observe on its own (the tab switch). A surface that is
|
||||
/// hidden when it hears this releases its slot instead.
|
||||
signal artworkRefresh()
|
||||
|
||||
// The folder-detail cover set is SMALL and fully mounted, so the whole
|
||||
// set is one window report (the grids/lists window themselves).
|
||||
onFolderDetailChanged: {
|
||||
if (folderDetail) queueWindowReport(folderDetail.subfolders, 0,
|
||||
folderDetail.subfolders.length - 1)
|
||||
}
|
||||
onEphemeralChanged: {
|
||||
if (ephemeral && ephemeral.albums) {
|
||||
queueWindowReport(ephemeral.albums, 0, ephemeral.albums.length - 1)
|
||||
//
|
||||
// BOTH of its cover sections contribute. The report used to carry the
|
||||
// subfolder cards only, while LocalFolderDetail ALSO binds `artMap` on
|
||||
// the folder's direct track rows — those rows asked for a key nobody had
|
||||
// requested, so their 36px cell stayed blank forever. The track half is
|
||||
// gated on `trackArtwork` (default OFF), which is why this is a derived
|
||||
// property: flipping the setting re-derives it and re-reports by itself.
|
||||
readonly property var folderDetailArtRows: {
|
||||
if (!folderDetail) return []
|
||||
var out = (folderDetail.subfolders || []).slice()
|
||||
if (trackArtwork && folderDetail.tracks) {
|
||||
for (var i = 0; i < folderDetail.tracks.length; i++)
|
||||
out.push(folderDetail.tracks[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
function reportFolderDetailWindow() {
|
||||
var rows = folderDetailArtRows
|
||||
if (rows.length === 0) { releaseWindow("folder-detail"); return }
|
||||
queueWindowReport(rows, 0, rows.length - 1, "folder-detail")
|
||||
}
|
||||
onFolderDetailArtRowsChanged: reportFolderDetailWindow()
|
||||
|
||||
function reportEphemeralWindow() {
|
||||
var rows = (ephemeral && ephemeral.albums) ? ephemeral.albums : []
|
||||
if (rows.length === 0) { releaseWindow("ephemeral"); return }
|
||||
queueWindowReport(rows, 0, rows.length - 1, "ephemeral")
|
||||
}
|
||||
onEphemeralChanged: reportEphemeralWindow()
|
||||
|
||||
// ---------------------- derived (JS, per-tab) -------------------------
|
||||
function sortRows(rows, key) {
|
||||
@@ -369,33 +403,91 @@ Rectangle {
|
||||
|
||||
// --------------------- windowed artwork ------------------------------
|
||||
// Report the mounted window as artKeys and prune covers a full window
|
||||
// away. Identical policy to LibraryView (the Slint eviction, QML-side).
|
||||
function reportWindow(rows, first, last) {
|
||||
if (!rows || rows.length === 0) return
|
||||
// away. Same policy as LibraryView (the Slint eviction, QML-side), with
|
||||
// one correction the port needed: eviction is derived from EVERY live
|
||||
// surface at once, not from the one array the current call carries.
|
||||
//
|
||||
// WHY A REGISTRY AND NOT A SINGLE WINDOW. This view mounts TWO cover
|
||||
// surfaces side by side more often than not: the Artists tab is the
|
||||
// avatar rail plus that artist's album grid, and Folders tree mode is the
|
||||
// subfolder cards plus the folder's track rows. The old per-call eviction
|
||||
// built `keep` from its own rows, so each surface deleted the other's
|
||||
// covers on every report and both settled on grey squares. `_windows`
|
||||
// holds the last window of every live surface keyed by a stable surface
|
||||
// id, and the keep-set is their union.
|
||||
property var _windows: ({})
|
||||
|
||||
/// Record (or drop) one surface's window. An empty or degenerate window
|
||||
/// RELEASES the slot rather than pinning covers the surface no longer
|
||||
/// shows.
|
||||
function applyWindow(key, rows, first, last) {
|
||||
if (!rows || rows.length === 0) { delete root._windows[key]; return }
|
||||
last = Math.min(last, rows.length - 1)
|
||||
first = Math.max(0, first)
|
||||
if (first > last) return
|
||||
var keys = []
|
||||
if (first > last) { delete root._windows[key]; return }
|
||||
root._windows[key] = { "rows": rows, "first": first, "last": last }
|
||||
}
|
||||
|
||||
/// A surface that unmounts, or scrolls/tabs off screen, stops holding its
|
||||
/// covers. Cheap no-op when the surface was never registered.
|
||||
function releaseWindow(key) {
|
||||
var k = key || "default"
|
||||
if (root._windows[k] === undefined && root._pending[k] === undefined) return
|
||||
delete root._windows[k]
|
||||
delete root._pending[k]
|
||||
flushWindows()
|
||||
}
|
||||
|
||||
/// Evict against the union of every live window, then request what is
|
||||
/// still missing. Keys already resolved are NOT re-sent: `artMap` IS the
|
||||
/// resolved set and a re-request costs Rust a `stat` per key
|
||||
/// (local_artwork.rs phase 1), which a scroll would pay per pass.
|
||||
function flushWindows() {
|
||||
var keep = {}
|
||||
var span = last - first + 1
|
||||
var lo = Math.max(0, first - span)
|
||||
var hi = Math.min(rows.length - 1, last + span)
|
||||
var i
|
||||
for (i = lo; i <= hi; i++) if (rows[i].artKey) keep[rows[i].artKey] = true
|
||||
for (i = first; i <= last; i++) if (rows[i].artKey) keys.push(rows[i].artKey)
|
||||
var k, w, rows, i, ak, span, lo, hi
|
||||
for (k in root._windows) {
|
||||
w = root._windows[k]
|
||||
rows = w.rows
|
||||
span = w.last - w.first + 1
|
||||
lo = Math.max(0, w.first - span)
|
||||
hi = Math.min(rows.length - 1, w.last + span)
|
||||
for (i = lo; i <= hi; i++) {
|
||||
ak = rows[i] ? rows[i].artKey : ""
|
||||
if (ak) keep[ak] = true
|
||||
}
|
||||
}
|
||||
var m = root.artMap
|
||||
var changed = false
|
||||
for (var key in m) {
|
||||
if (!keep[key]) { delete m[key]; changed = true }
|
||||
}
|
||||
for (k in m) if (!keep[k]) { delete m[k]; changed = true }
|
||||
// The not-yet-flushed arrivals are evicted too, or a cover that landed
|
||||
// for a row we have just scrolled away from would be re-added by the
|
||||
// next flush and defeat the eviction.
|
||||
for (key in root._artInbox) if (!keep[key]) delete root._artInbox[key]
|
||||
for (k in root._artInbox) if (!keep[k]) delete root._artInbox[k]
|
||||
if (changed) root.artMap = Object.assign({}, m)
|
||||
|
||||
var keys = []
|
||||
var seen = {}
|
||||
for (k in root._windows) {
|
||||
w = root._windows[k]
|
||||
rows = w.rows
|
||||
for (i = w.first; i <= w.last; i++) {
|
||||
ak = rows[i] ? rows[i].artKey : ""
|
||||
if (!ak || seen[ak]) continue
|
||||
if (m[ak] !== undefined || root._artInbox[ak] !== undefined) continue
|
||||
seen[ak] = true
|
||||
keys.push(ak)
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
|
||||
}
|
||||
|
||||
/// Immediate single-surface report — the leading edge and the rate-limit
|
||||
/// flush both land here.
|
||||
function reportWindow(rows, first, last, key) {
|
||||
applyWindow(key || "default", rows, first, last)
|
||||
flushWindows()
|
||||
}
|
||||
|
||||
// ------------------------- skeleton pulse ----------------------------
|
||||
// ONE 900ms Timer drives EVERY placeholder in this view AND in every
|
||||
// tab body under views/local/ (they read `view.skelPhase`) — N
|
||||
@@ -463,36 +555,54 @@ Rectangle {
|
||||
/// The decoded cover for a row, "" until it lands.
|
||||
function artPathOf(key) { return root.artMap[key] || "" }
|
||||
|
||||
// Reporting is throttled at 180ms, but LEADING-EDGE: the debounce exists
|
||||
// to stop a scroll from firing a resolution pass per pixel, and a view
|
||||
// that has just mounted is not scrolling. Trailing-only cost every first
|
||||
// paint a flat 180ms before Rust was even asked for a cover — pure
|
||||
// latency on the one report that the user is actually waiting for.
|
||||
// So: report at once if the last pass is older than the window, and
|
||||
// debounce only the reports that arrive inside it.
|
||||
// Reporting is rate-limited to one pass per 180ms, but LEADING-EDGE: the
|
||||
// limiter exists to stop a scroll from firing a resolution pass per pixel,
|
||||
// and a view that has just mounted is not scrolling. Trailing-only cost
|
||||
// every first paint a flat 180ms before Rust was even asked for a cover —
|
||||
// pure latency on the one report the user is actually waiting for.
|
||||
//
|
||||
// TWO THINGS THIS USED TO SWALLOW, both of which read as "grey squares
|
||||
// until I scroll":
|
||||
// - ONE pending slot for the whole view. Two surfaces reporting inside
|
||||
// the same 180ms (the artist rail and the artist's album grid do
|
||||
// exactly that on every selection) meant the second overwrote the
|
||||
// first and one pane was never requested at all. Pending reports are
|
||||
// keyed by surface now and ALL of them flush.
|
||||
// - `restart()` on every call. That is a reset-on-change debounce: a
|
||||
// continuous flick (or a burst of mount-time reports — rows arrive,
|
||||
// the column count settles, the tab becomes visible) pushed the
|
||||
// trailing edge out by another 180ms each time, so the pass fired
|
||||
// only once everything stopped moving. It is a rate limiter now:
|
||||
// `start()`, never `restart()`.
|
||||
Timer {
|
||||
id: windowDebounce
|
||||
interval: 180
|
||||
property var pendingRows: []
|
||||
property int pendingFirst: 0
|
||||
property int pendingLast: 0
|
||||
onTriggered: {
|
||||
root._lastReportMs = Date.now()
|
||||
root.reportWindow(pendingRows, pendingFirst, pendingLast)
|
||||
var pending = root._pending
|
||||
root._pending = ({})
|
||||
var any = false
|
||||
for (var k in pending) {
|
||||
var w = pending[k]
|
||||
root.applyWindow(k, w.rows, w.first, w.last)
|
||||
any = true
|
||||
}
|
||||
if (any) root.flushWindows()
|
||||
}
|
||||
}
|
||||
/// Surface id -> the window waiting for the next flush.
|
||||
property var _pending: ({})
|
||||
// `real` is a double in QML — Date.now() (~1.7e12) needs the range.
|
||||
property real _lastReportMs: 0
|
||||
function queueWindowReport(rows, first, last) {
|
||||
windowDebounce.pendingRows = rows
|
||||
windowDebounce.pendingFirst = first
|
||||
windowDebounce.pendingLast = last
|
||||
function queueWindowReport(rows, first, last, key) {
|
||||
var k = key || "default"
|
||||
if (!windowDebounce.running
|
||||
&& Date.now() - root._lastReportMs >= windowDebounce.interval) {
|
||||
root._lastReportMs = Date.now()
|
||||
root.reportWindow(rows, first, last)
|
||||
root.reportWindow(rows, first, last, k)
|
||||
} else {
|
||||
windowDebounce.restart()
|
||||
root._pending[k] = { "rows": rows, "first": first, "last": last }
|
||||
if (!windowDebounce.running) windowDebounce.start()
|
||||
}
|
||||
// Covers can now land: keep the shared pulse (and every placeholder's
|
||||
// settle hold) alive until artPassMs after the LAST sign of life —
|
||||
|
||||
@@ -27,6 +27,10 @@ Item {
|
||||
|
||||
/// The LocalLibraryView root — artMap + queueWindowReport live there.
|
||||
property var view: null
|
||||
/// Stable id for this cover surface in the host's window registry. Three
|
||||
/// instances of this component can be alive at once (Albums, Folders-flat,
|
||||
/// the Artists detail grid), and they must not share a slot.
|
||||
property string surface: "collection"
|
||||
/// Flat album rows (already searched/sorted/filtered by the host).
|
||||
property var rows: []
|
||||
/// [{ letter, items: [...] }] — only read when `grouped`.
|
||||
@@ -97,13 +101,26 @@ Item {
|
||||
onGroupedChanged: rebuild()
|
||||
onViewModeChanged: rebuild()
|
||||
onColsChanged: rebuild()
|
||||
Component.onCompleted: rebuild()
|
||||
Component.onCompleted: { rebuild(); reportSoon() }
|
||||
Component.onDestruction: if (view) view.releaseWindow(root.surface)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Window report — the MOUNTED entry band, mapped back to flat indices.
|
||||
// ---------------------------------------------------------------------
|
||||
// TRIGGERS. A report used to leave here on `contentY` and on the model
|
||||
// swap alone, and BOTH of those fire while this surface is still hidden:
|
||||
// the tab body is mounted behind `localAlbumsLoading` and the rows land
|
||||
// in the same pass, so the one report that mattered was thrown away by
|
||||
// the `!visible` guard below and nothing was requested until the user
|
||||
// moved the list by a pixel. Every state change that can alter the
|
||||
// mounted band now reports: mount, becoming visible, the model rebuild,
|
||||
// a viewport resize, and the scroll.
|
||||
function report() {
|
||||
if (!view || !list || !visible || entries.length === 0) return
|
||||
if (!view || !list) return
|
||||
if (!list.visible || entries.length === 0 || width <= 0) {
|
||||
view.releaseWindow(root.surface)
|
||||
return
|
||||
}
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
@@ -112,7 +129,33 @@ Item {
|
||||
var hi = entries[last]
|
||||
var loIdx = lo ? (lo.t === 1 ? lo.base : 0) : 0
|
||||
var hiIdx = hi ? (hi.t === 1 ? hi.base + hi.items.length - 1 : loIdx) : loIdx
|
||||
view.queueWindowReport(flat, loIdx, hiIdx)
|
||||
view.queueWindowReport(flat, loIdx, hiIdx, root.surface)
|
||||
}
|
||||
|
||||
/// Report NOW, then again once layout has settled. `indexAt` answers -1
|
||||
/// until the ListView has laid its delegates out, which is exactly the
|
||||
/// state a just-mounted / just-shown list is in — the immediate call gets
|
||||
/// the covers moving off the conservative fallback band, the second one
|
||||
/// corrects it to the real viewport.
|
||||
Timer {
|
||||
id: reportSettle
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: root.report()
|
||||
}
|
||||
function reportSoon() {
|
||||
report()
|
||||
reportSettle.restart()
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) reportSoon()
|
||||
else if (view) view.releaseWindow(root.surface)
|
||||
}
|
||||
onHeightChanged: report()
|
||||
Connections {
|
||||
target: root.view
|
||||
function onArtworkRefresh() { root.reportSoon() }
|
||||
}
|
||||
|
||||
function jumpToEntry(entryIndex) {
|
||||
@@ -128,6 +171,8 @@ Item {
|
||||
model: root.entries
|
||||
onContentYChanged: root.report()
|
||||
onModelChanged: root.report()
|
||||
onHeightChanged: root.report()
|
||||
onVisibleChanged: if (visible) root.reportSoon()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
|
||||
@@ -114,6 +114,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.view.albumsGroup === "alpha" ? 20 : 0
|
||||
view: root.view
|
||||
surface: "albums"
|
||||
rows: root.view.albumsVisible
|
||||
groups: root.view.albumsGrouped
|
||||
grouped: root.view.albumsGroup !== "off"
|
||||
|
||||
@@ -24,24 +24,44 @@ Item {
|
||||
// --------------------------- entry model ------------------------------
|
||||
property var entries: []
|
||||
property var alphaJumps: []
|
||||
/// The rail's rows in DISPLAY order — the A-Z grouping reorders them, so
|
||||
/// this is NOT `view.artistsVisible` and the window report must index
|
||||
/// into it instead. (Reporting entry indices against `artistsVisible`
|
||||
/// asked for the wrong avatars by the number of letter headers scrolled
|
||||
/// past — up to 27 rows off at the bottom of the rail.)
|
||||
property var flat: []
|
||||
/// entry index -> index into `flat`, -1 for a letter header.
|
||||
property var flatIndex: []
|
||||
function rebuild() {
|
||||
var groups = view ? view.artistsGrouped : []
|
||||
var out = []
|
||||
var jumps = []
|
||||
var flatOut = []
|
||||
var idx = []
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
var g = groups[i]
|
||||
if (!g.items || g.items.length === 0) continue
|
||||
jumps.push({ "letter": g.letter, "index": out.length })
|
||||
out.push({ "t": 0, "label": g.letter })
|
||||
for (var j = 0; j < g.items.length; j++) out.push({ "t": 1, "item": g.items[j] })
|
||||
idx.push(-1)
|
||||
for (var j = 0; j < g.items.length; j++) {
|
||||
out.push({ "t": 1, "item": g.items[j] })
|
||||
idx.push(flatOut.length)
|
||||
flatOut.push(g.items[j])
|
||||
}
|
||||
}
|
||||
entries = out
|
||||
alphaJumps = jumps
|
||||
flat = flatOut
|
||||
flatIndex = idx
|
||||
report()
|
||||
}
|
||||
Component.onCompleted: rebuild()
|
||||
Component.onCompleted: { rebuild(); reportSoon() }
|
||||
Component.onDestruction: if (view) view.releaseWindow("artists-rail")
|
||||
Connections {
|
||||
target: root.view
|
||||
function onArtistsGroupedChanged() { root.rebuild() }
|
||||
function onArtworkRefresh() { root.reportSoon() }
|
||||
}
|
||||
|
||||
// Loading = the shape of the master/detail pair that is coming: 60px
|
||||
@@ -140,6 +160,11 @@ Item {
|
||||
model: root.entries
|
||||
onContentYChanged: root.report()
|
||||
onModelChanged: root.report()
|
||||
onHeightChanged: root.report()
|
||||
onVisibleChanged: {
|
||||
if (visible) root.reportSoon()
|
||||
else if (root.view) root.view.releaseWindow("artists-rail")
|
||||
}
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
@@ -257,6 +282,7 @@ Item {
|
||||
width: parent.width
|
||||
height: parent.height - 60
|
||||
view: root.view
|
||||
surface: "artist-albums"
|
||||
rows: root.view.artistAlbums
|
||||
grouped: false
|
||||
viewMode: "grid"
|
||||
@@ -271,12 +297,47 @@ Item {
|
||||
|
||||
// Artist avatars ride the same id-keyed artwork window as every other
|
||||
// cover; the rail reports its mounted band.
|
||||
//
|
||||
// TRIGGERS: the rail used to report on `contentY` and the model swap
|
||||
// only, and both of those fire while the pane is still behind
|
||||
// `localArtistsLoading` — the report was made against a ListView that had
|
||||
// not laid out, and nothing re-fired once the rows appeared. It now also
|
||||
// reports on mount, on the rebuild, when the pane becomes visible and
|
||||
// when the viewport resizes.
|
||||
function report() {
|
||||
if (!view || !rail) return
|
||||
if (!rail.visible || root.entries.length === 0) {
|
||||
view.releaseWindow("artists-rail")
|
||||
return
|
||||
}
|
||||
var first = rail.indexAt(4, rail.contentY + 1)
|
||||
var last = rail.indexAt(4, rail.contentY + Math.max(1, rail.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = first + 10
|
||||
view.queueWindowReport(view.artistsVisible, Math.max(0, first - 4), last + 4)
|
||||
if (last < 0) last = Math.min(root.entries.length - 1, first + 12)
|
||||
// Entry band -> rail-row band (letter headers do not carry a cover).
|
||||
var lo = -1
|
||||
var hi = -1
|
||||
for (var i = first; i <= last; i++) {
|
||||
var n = root.flatIndex[i]
|
||||
if (n === undefined || n < 0) continue
|
||||
if (lo < 0) lo = n
|
||||
hi = n
|
||||
}
|
||||
if (lo < 0) { lo = 0; hi = Math.min(root.flat.length - 1, 12) }
|
||||
view.queueWindowReport(root.flat, Math.max(0, lo - 4), hi + 4, "artists-rail")
|
||||
}
|
||||
|
||||
/// Report now, then again once the ListView has laid out — `indexAt`
|
||||
/// answers -1 before that, which is exactly the state of a pane that has
|
||||
/// just been shown.
|
||||
Timer {
|
||||
id: reportSettle
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: root.report()
|
||||
}
|
||||
function reportSoon() {
|
||||
report()
|
||||
reportSettle.restart()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,25 @@ Item {
|
||||
readonly property var eph: view ? view.ephemeral : null
|
||||
readonly property var albums: eph && eph.albums ? eph.albums : []
|
||||
|
||||
// Same rule as LocalFolderDetail: the host reports this pane's covers as
|
||||
// one window when the document changes, so re-opening the pane on an
|
||||
// unchanged document (leaving and returning to the Folders tab) needs its
|
||||
// own trigger, or the evicted covers never come back.
|
||||
Component.onCompleted: if (view && visible) view.reportEphemeralWindow()
|
||||
Component.onDestruction: if (view) view.releaseWindow("ephemeral")
|
||||
onVisibleChanged: {
|
||||
if (!view) return
|
||||
if (visible) view.reportEphemeralWindow()
|
||||
else view.releaseWindow("ephemeral")
|
||||
}
|
||||
Connections {
|
||||
target: root.view
|
||||
function onArtworkRefresh() {
|
||||
if (root.visible) root.view.reportEphemeralWindow()
|
||||
else root.view.releaseWindow("ephemeral")
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: pane
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -24,6 +24,27 @@ Item {
|
||||
|
||||
readonly property var detail: view ? view.folderDetail : null
|
||||
|
||||
// The host reports this pane's covers as ONE window (the set is small and
|
||||
// fully mounted — no windowing here). That report fires when the DOCUMENT
|
||||
// changes, which is not enough on its own: switching Folders back to tree
|
||||
// mode, or returning to the tab, re-shows a pane whose document has not
|
||||
// changed since, and the covers had been evicted meanwhile. Re-report
|
||||
// whenever the pane comes back on screen, and let go when it leaves.
|
||||
Component.onCompleted: if (view && visible) view.reportFolderDetailWindow()
|
||||
Component.onDestruction: if (view) view.releaseWindow("folder-detail")
|
||||
onVisibleChanged: {
|
||||
if (!view) return
|
||||
if (visible) view.reportFolderDetailWindow()
|
||||
else view.releaseWindow("folder-detail")
|
||||
}
|
||||
Connections {
|
||||
target: root.view
|
||||
function onArtworkRefresh() {
|
||||
if (root.visible) root.view.reportFolderDetailWindow()
|
||||
else root.view.releaseWindow("folder-detail")
|
||||
}
|
||||
}
|
||||
|
||||
LocalNote {
|
||||
visible: root.view.selectedFolder === ""
|
||||
text: QbzSession.tr("Select a folder to see its contents.", QbzSession.trRev)
|
||||
|
||||
@@ -95,6 +95,7 @@ Item {
|
||||
anchors.topMargin: 16
|
||||
visible: root.view.foldersVisible.length > 0
|
||||
view: root.view
|
||||
surface: "folders-flat"
|
||||
rows: root.view.foldersVisible
|
||||
groups: root.view.foldersGrouped
|
||||
grouped: root.view.foldersGroup !== "off"
|
||||
|
||||
@@ -27,11 +27,17 @@ Item {
|
||||
// --------------------------- entry model ------------------------------
|
||||
property var entries: []
|
||||
property var alphaJumps: []
|
||||
/// entry index -> index into `view.tracksVisible`, -1 for a group header.
|
||||
/// Group headers inflate the entry indices, so reporting them straight
|
||||
/// into the row array asked for the covers of tracks BELOW the ones on
|
||||
/// screen, by the number of headers scrolled past.
|
||||
property var rowIndex: []
|
||||
function rebuild() {
|
||||
var rows = view ? view.tracksVisible : []
|
||||
var mode = view ? view.tracksGroup : "off"
|
||||
var out = []
|
||||
var jumps = []
|
||||
var idx = []
|
||||
var prev = null
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var t = rows[i]
|
||||
@@ -42,18 +48,27 @@ Item {
|
||||
if (mode !== "off" && key !== "" && key !== prev) {
|
||||
if (mode === "name") jumps.push({ "letter": key, "index": out.length })
|
||||
out.push({ "t": 0, "label": key })
|
||||
idx.push(-1)
|
||||
prev = key
|
||||
}
|
||||
out.push({ "t": 1, "row": t, "n": i + 1 })
|
||||
idx.push(i)
|
||||
}
|
||||
entries = out
|
||||
alphaJumps = jumps
|
||||
rowIndex = idx
|
||||
report()
|
||||
}
|
||||
Component.onCompleted: rebuild()
|
||||
Component.onCompleted: { rebuild(); reportSoon() }
|
||||
Component.onDestruction: if (view) view.releaseWindow("tracks")
|
||||
Connections {
|
||||
target: root.view
|
||||
function onTracksVisibleChanged() { root.rebuild() }
|
||||
function onTracksGroupChanged() { root.rebuild() }
|
||||
// The row artwork column is OFF by default; turning it on has to ask
|
||||
// for the covers the rows are already bound to.
|
||||
function onTrackArtworkChanged() { root.reportSoon() }
|
||||
function onArtworkRefresh() { root.reportSoon() }
|
||||
}
|
||||
|
||||
// First page = the shape of the 50px track rows (the Slint mounts a bare
|
||||
@@ -132,6 +147,11 @@ Item {
|
||||
}
|
||||
}
|
||||
onModelChanged: root.report()
|
||||
onHeightChanged: root.report()
|
||||
onVisibleChanged: {
|
||||
if (visible) root.reportSoon()
|
||||
else if (root.view) root.view.releaseWindow("tracks")
|
||||
}
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
@@ -222,14 +242,46 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// TRIGGERS: `contentY` + the model swap alone never fired for a list that
|
||||
// was still hidden behind `localTracksLoading` when its first page landed
|
||||
// — the covers only started resolving on the first scroll. Mount, the
|
||||
// rebuild, becoming visible and a resize report too.
|
||||
function report() {
|
||||
if (!view || !list) return
|
||||
// The row artwork column is gated on the appearance setting (default
|
||||
// OFF, for the 16K-track freeze). With it off nothing on this list
|
||||
// renders a cover, so nothing is worth decoding.
|
||||
if (!list.visible || !view.trackArtwork || root.entries.length === 0) {
|
||||
view.releaseWindow("tracks")
|
||||
return
|
||||
}
|
||||
var first = list.indexAt(4, list.contentY + 1)
|
||||
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
||||
if (first < 0) first = 0
|
||||
if (last < 0) last = first + 12
|
||||
// Entry indices are >= row indices (headers inflate them), so the
|
||||
// clamp inside queueWindowReport keeps this safe.
|
||||
view.queueWindowReport(view.tracksVisible, Math.max(0, first - 4), last + 4)
|
||||
if (last < 0) last = Math.min(root.entries.length - 1, first + 12)
|
||||
// Entry band -> row band (group headers carry no cover).
|
||||
var lo = -1
|
||||
var hi = -1
|
||||
for (var i = first; i <= last; i++) {
|
||||
var n = root.rowIndex[i]
|
||||
if (n === undefined || n < 0) continue
|
||||
if (lo < 0) lo = n
|
||||
hi = n
|
||||
}
|
||||
if (lo < 0) { lo = 0; hi = Math.min(view.tracksVisible.length - 1, 12) }
|
||||
view.queueWindowReport(view.tracksVisible, Math.max(0, lo - 4), hi + 4, "tracks")
|
||||
}
|
||||
|
||||
/// Report now, then again once the ListView has laid out (`indexAt`
|
||||
/// answers -1 until then — the state a just-shown list is in).
|
||||
Timer {
|
||||
id: reportSettle
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: root.report()
|
||||
}
|
||||
function reportSoon() {
|
||||
report()
|
||||
reportSettle.restart()
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,20 @@ pub mod qbz_bridge {
|
||||
// rows + ownership/follow/pin/sort/search state).
|
||||
#[qproperty(QString, playlist_json)]
|
||||
|
||||
// --- Filter by genre (shared, per context) -------------------------
|
||||
// ONE JSON document (genre_filter_qt.rs FilterDoc): the popup model
|
||||
// (chips / tree / remember / advanced / loading) PLUS the per-context
|
||||
// selection counts and selected genre NAMES. Every surface that draws
|
||||
// a genre button reads this one property: Discover (context
|
||||
// "discover", server-side via get_discover_index) and Library > All
|
||||
// (context "library-all", client-side over the feed).
|
||||
#[qproperty(QString, genre_filter_json)]
|
||||
|
||||
// --- Discover section configurator (the toolbar gear) --------------
|
||||
// ONE JSON document (discover_config_qt.rs ConfigDoc: the active
|
||||
// tab's ordered rows + enabled/total counts).
|
||||
#[qproperty(QString, discover_config_json)]
|
||||
|
||||
type QbzBridge = super::QbzBridgeRust;
|
||||
|
||||
/// TEMP (phase 23 split): registers the QbzBridge Qt-thread
|
||||
@@ -165,6 +179,51 @@ pub mod qbz_bridge {
|
||||
/// Card-level playlist actions (LibPlaylistCard overlay + menu).
|
||||
#[qinvokable]
|
||||
fn playlist_set_follow_by_id(self: Pin<&mut QbzBridge>, playlist_id: QString, follow: bool);
|
||||
|
||||
// --- Filter by genre -----------------------------------------------
|
||||
/// Genre button click: switch the edited context ("discover" |
|
||||
/// "library-all"), publish the popup model, lazy-load the parent
|
||||
/// genres on first open.
|
||||
#[qinvokable]
|
||||
fn genre_open(self: Pin<&mut QbzBridge>, context: QString);
|
||||
/// Chip / tree-row click: flip the id in the CURRENT context, persist,
|
||||
/// and refresh that context's consumer.
|
||||
#[qinvokable]
|
||||
fn genre_toggle(self: Pin<&mut QbzBridge>, genre_id: QString);
|
||||
/// Advanced tree chevron: expand/collapse + lazy-load that level.
|
||||
#[qinvokable]
|
||||
fn genre_toggle_expand(self: Pin<&mut QbzBridge>, genre_id: QString);
|
||||
/// Advanced-view search box (filters the loaded genres, flat).
|
||||
#[qinvokable]
|
||||
fn genre_search(self: Pin<&mut QbzBridge>, query: QString);
|
||||
/// "Clear filter" — drops the CURRENT context's selection.
|
||||
#[qinvokable]
|
||||
fn genre_clear(self: Pin<&mut QbzBridge>);
|
||||
/// "Remember selection" toggle (off also deletes the persisted file).
|
||||
#[qinvokable]
|
||||
fn genre_set_remember(self: Pin<&mut QbzBridge>, value: bool);
|
||||
/// "Advanced view" toggle (eager-loads every parent's children).
|
||||
#[qinvokable]
|
||||
fn genre_set_advanced(self: Pin<&mut QbzBridge>, value: bool);
|
||||
|
||||
// --- Discover section configurator ---------------------------------
|
||||
/// Gear click: publish the rows for the tab the modal will show.
|
||||
#[qinvokable]
|
||||
fn discover_config_open(self: Pin<&mut QbzBridge>, tab: QString);
|
||||
/// Row click / checkbox: show or hide that section on that tab.
|
||||
#[qinvokable]
|
||||
fn discover_toggle_section(self: Pin<&mut QbzBridge>, tab: QString, section_id: QString);
|
||||
/// Reorder chevrons: -1 up, +1 down (clamped at the boundaries).
|
||||
#[qinvokable]
|
||||
fn discover_move_section(
|
||||
self: Pin<&mut QbzBridge>,
|
||||
tab: QString,
|
||||
section_id: QString,
|
||||
dir: i32,
|
||||
);
|
||||
/// "Reset to defaults" — that tab only.
|
||||
#[qinvokable]
|
||||
fn discover_reset_tab(self: Pin<&mut QbzBridge>, tab: QString);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzBridge {}
|
||||
@@ -188,6 +247,8 @@ pub struct QbzBridgeRust {
|
||||
search_json: QString,
|
||||
intelligent_search: bool,
|
||||
playlist_json: QString,
|
||||
genre_filter_json: QString,
|
||||
discover_config_json: QString,
|
||||
}
|
||||
|
||||
impl Default for QbzBridgeRust {
|
||||
@@ -202,6 +263,16 @@ impl Default for QbzBridgeRust {
|
||||
search_json: QString::from("{}"),
|
||||
intelligent_search: crate::search_qt::intelligent_search_pref(),
|
||||
playlist_json: QString::from("{}"),
|
||||
// Seeded from the PERSISTED selection (no network): a remembered
|
||||
// filter colors both genre buttons and narrows the Library feed
|
||||
// before the popup has ever been opened.
|
||||
genre_filter_json: QString::from(
|
||||
crate::genre_filter_qt::current_json().as_str(),
|
||||
),
|
||||
// Seeded for the tab Discover mounts on.
|
||||
discover_config_json: QString::from(
|
||||
crate::discover_config_qt::rows_json("home").as_str(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,4 +419,48 @@ impl qbz_bridge::QbzBridge {
|
||||
crate::playlist_set_follow_by_id(pid, follow);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn genre_open(self: Pin<&mut Self>, context: QString) {
|
||||
crate::genre_filter_qt::open(&context.to_string());
|
||||
}
|
||||
|
||||
pub fn genre_toggle(self: Pin<&mut Self>, genre_id: QString) {
|
||||
crate::genre_filter_qt::toggle(&genre_id.to_string());
|
||||
}
|
||||
|
||||
pub fn genre_toggle_expand(self: Pin<&mut Self>, genre_id: QString) {
|
||||
crate::genre_filter_qt::toggle_expand(&genre_id.to_string());
|
||||
}
|
||||
|
||||
pub fn genre_search(self: Pin<&mut Self>, query: QString) {
|
||||
crate::genre_filter_qt::set_search(&query.to_string());
|
||||
}
|
||||
|
||||
pub fn genre_clear(self: Pin<&mut Self>) {
|
||||
crate::genre_filter_qt::clear();
|
||||
}
|
||||
|
||||
pub fn genre_set_remember(self: Pin<&mut Self>, value: bool) {
|
||||
crate::genre_filter_qt::set_remember(value);
|
||||
}
|
||||
|
||||
pub fn genre_set_advanced(self: Pin<&mut Self>, value: bool) {
|
||||
crate::genre_filter_qt::set_advanced(value);
|
||||
}
|
||||
|
||||
pub fn discover_config_open(self: Pin<&mut Self>, tab: QString) {
|
||||
crate::discover_config_qt::open(&tab.to_string());
|
||||
}
|
||||
|
||||
pub fn discover_toggle_section(self: Pin<&mut Self>, tab: QString, section_id: QString) {
|
||||
crate::discover_config_qt::toggle_section(&tab.to_string(), §ion_id.to_string());
|
||||
}
|
||||
|
||||
pub fn discover_move_section(self: Pin<&mut Self>, tab: QString, section_id: QString, dir: i32) {
|
||||
crate::discover_config_qt::move_section(&tab.to_string(), §ion_id.to_string(), dir);
|
||||
}
|
||||
|
||||
pub fn discover_reset_tab(self: Pin<&mut Self>, tab: QString) {
|
||||
crate::discover_config_qt::reset_tab(&tab.to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Discover section configurator — Slint-free port of the mutation half of
|
||||
//! `crates/qbz/src/discover_prefs.rs` (`push_config_rows` /
|
||||
//! `on_toggle` / `on_move` / `on_reset`).
|
||||
//!
|
||||
//! The gear in the Discover toolbar opens the per-tab "Customize" modal:
|
||||
//! show/hide + reorder the active tab's rails. There is no Save button —
|
||||
//! every toggle / move / reset persists live to the SHARED per-user
|
||||
//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, the same store
|
||||
//! `home_qt::load_prefs` already reads) and then re-renders the three tabs
|
||||
//! from the cached candidate set, so the change lands on the next frame with
|
||||
//! no network round trip.
|
||||
//!
|
||||
//! Transport: ONE JSON document on the bridge (`discoverConfigJson`) —
|
||||
//! `{ tab, rows: [{ id, label, enabled }], enabled, total }` — parsed by
|
||||
//! `qml/controls/DiscoverConfigModal.qml`.
|
||||
//!
|
||||
//! POC-NOTEs vs the Slint controller:
|
||||
//! - Rows are filtered to the sections this port can actually RENDER
|
||||
//! (`home_qt::renderable_ids`). The Slint modal lists every pref entry
|
||||
//! because Slint implements every rail; here a reco-engine / radio /
|
||||
//! spotlight row would be a toggle that changes nothing, which is exactly
|
||||
//! the defect class this port removes.
|
||||
//! - The Recommendations tab's arm of the Slint modal (explainer + cache
|
||||
//! window + "Refresh now") is NOT ported: it drives
|
||||
//! `ExternalRecoState`/`ExternalRecoActions`, and the external reco engine
|
||||
//! is out of scope. The toolbar gear is DISABLED on that tab instead of
|
||||
//! opening an empty modal (HomeView.qml).
|
||||
|
||||
use qbz_app::settings::discover_prefs::{
|
||||
DiscoverPrefs, DiscoverPrefsStore, DiscoverySectionId, DiscoveryTab,
|
||||
};
|
||||
use cxx_qt_lib::QString;
|
||||
use serde::Serialize;
|
||||
|
||||
/// id -> label. Verbatim copy of `discover_prefs::label_for` in the Slint
|
||||
/// binary crate (not reachable from here — it lives in `crates/qbz`, and the
|
||||
/// headless prefs crate deliberately carries no frontend strings, ADR-006).
|
||||
/// `mark` registers the msgid for the extractor; `t` does the lookup once at
|
||||
/// the consumer.
|
||||
fn label_for(id: DiscoverySectionId) -> &'static str {
|
||||
use DiscoverySectionId::*;
|
||||
match id {
|
||||
NewReleases => qbz_i18n::mark("New Releases"),
|
||||
PressAwards => qbz_i18n::mark("Press Accolades"),
|
||||
QobuzPlaylists => qbz_i18n::mark("Qobuz Playlists"),
|
||||
RecentlyPlayedAlbums => qbz_i18n::mark("Recently Played"),
|
||||
ContinueListening => qbz_i18n::mark("Continue Listening"),
|
||||
IdealDiscography => qbz_i18n::mark("Ideal Discography"),
|
||||
MostStreamed => qbz_i18n::mark("Most Streamed"),
|
||||
ReleaseWatch => qbz_i18n::mark("Release Watch"),
|
||||
EditorPicks => qbz_i18n::mark("Albums of the Week"),
|
||||
Qobuzissimes => qbz_i18n::mark("Qobuzissimes"),
|
||||
TopArtists => qbz_i18n::mark("Your Top Artists"),
|
||||
FavoriteAlbums => qbz_i18n::mark("Library Albums"),
|
||||
QobuzMixes => qbz_i18n::mark("Qobuz Mixes"),
|
||||
RadioStations => qbz_i18n::mark("Radio Stations"),
|
||||
SimilarAlbums => qbz_i18n::mark("More From Your Library"),
|
||||
RediscoverLibrary => qbz_i18n::mark("Rediscover Your Library"),
|
||||
EssentialsByGenre => qbz_i18n::mark("Essentials by Genre"),
|
||||
ArtistsToFollow => qbz_i18n::mark("Artists to Follow"),
|
||||
ArtistSpotlight => qbz_i18n::mark("Artist Spotlight"),
|
||||
Pinned => qbz_i18n::mark("Pinned"),
|
||||
MostPlayedAlbums => qbz_i18n::mark("Most Played Albums"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RowDoc {
|
||||
id: String,
|
||||
label: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ConfigDoc {
|
||||
tab: String,
|
||||
rows: Vec<RowDoc>,
|
||||
enabled: i32,
|
||||
total: i32,
|
||||
}
|
||||
|
||||
fn store() -> Option<DiscoverPrefsStore> {
|
||||
crate::sidebar_qt::user_dir()
|
||||
.and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok())
|
||||
}
|
||||
|
||||
/// The tab's ordered pref ids, narrowed to what this port renders.
|
||||
fn visible_ids(prefs: &DiscoverPrefs, tab: DiscoveryTab) -> Vec<DiscoverySectionId> {
|
||||
let renderable = crate::home_qt::renderable_ids(tab);
|
||||
prefs
|
||||
.tab(tab)
|
||||
.iter()
|
||||
.map(|p| p.id)
|
||||
.filter(|id| renderable.is_empty() || renderable.iter().any(|r| r.as_str() == id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The modal payload for one tab.
|
||||
pub(crate) fn rows_json(tab_key: &str) -> String {
|
||||
let Some(tab) = DiscoveryTab::from_key(tab_key) else {
|
||||
// Recommendations (or an unknown key): no configurable sections.
|
||||
return serde_json::to_string(&ConfigDoc {
|
||||
tab: tab_key.to_string(),
|
||||
rows: Vec::new(),
|
||||
enabled: 0,
|
||||
total: 0,
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
};
|
||||
let prefs = crate::home_qt::load_prefs();
|
||||
let visible = visible_ids(&prefs, tab);
|
||||
let rows: Vec<RowDoc> = prefs
|
||||
.tab(tab)
|
||||
.iter()
|
||||
.filter(|p| visible.contains(&p.id))
|
||||
.map(|p| RowDoc {
|
||||
id: p.id.as_str().to_string(),
|
||||
label: qbz_i18n::t(label_for(p.id)),
|
||||
enabled: p.enabled,
|
||||
})
|
||||
.collect();
|
||||
let total = rows.len() as i32;
|
||||
let enabled = rows.iter().filter(|r| r.enabled).count() as i32;
|
||||
serde_json::to_string(&ConfigDoc {
|
||||
tab: tab_key.to_string(),
|
||||
rows,
|
||||
enabled,
|
||||
total,
|
||||
})
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn publish(tab_key: &str) {
|
||||
let json = rows_json(tab_key);
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_discover_config_json(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Gear click: (re)publish the rows for the tab the modal is about to show.
|
||||
pub(crate) fn open(tab_key: &str) {
|
||||
publish(tab_key);
|
||||
}
|
||||
|
||||
/// Load -> mutate -> persist -> re-render the tabs -> re-publish the rows.
|
||||
fn mutate(tab_key: &str, f: impl FnOnce(&mut DiscoverPrefs, DiscoveryTab)) {
|
||||
let Some(tab) = DiscoveryTab::from_key(tab_key) else {
|
||||
return;
|
||||
};
|
||||
let Some(store) = store() else {
|
||||
log::warn!("[qbz-qt] discover config: no per-user prefs store (not logged in?)");
|
||||
return;
|
||||
};
|
||||
let mut prefs = store.load();
|
||||
f(&mut prefs, tab);
|
||||
if let Err(e) = store.save(&prefs) {
|
||||
log::warn!("[qbz-qt] discover config: save failed: {e}");
|
||||
return;
|
||||
}
|
||||
crate::home_qt::republish_from_prefs();
|
||||
publish(tab_key);
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_section(tab_key: &str, id: &str) {
|
||||
let Some(section) = DiscoverySectionId::from_str(id) else {
|
||||
return;
|
||||
};
|
||||
mutate(tab_key, |prefs, tab| prefs.toggle(tab, section));
|
||||
}
|
||||
|
||||
/// Move one step through the VISIBLE order (`dir` = -1 up / +1 down).
|
||||
/// `DiscoverPrefs::move_section` swaps with the adjacent PREF entry, which
|
||||
/// may be a row this port filtered out — the click would then look dead. So
|
||||
/// the neighbour is picked from the visible list and the two entries are
|
||||
/// swapped in the full list (hidden entries keep their relative order).
|
||||
pub(crate) fn move_section(tab_key: &str, id: &str, dir: i32) {
|
||||
let Some(section) = DiscoverySectionId::from_str(id) else {
|
||||
return;
|
||||
};
|
||||
if dir == 0 {
|
||||
return;
|
||||
}
|
||||
mutate(tab_key, |prefs, tab| {
|
||||
let visible = visible_ids(prefs, tab);
|
||||
let Some(vi) = visible.iter().position(|v| *v == section) else {
|
||||
return;
|
||||
};
|
||||
let target = if dir < 0 {
|
||||
if vi == 0 {
|
||||
return;
|
||||
}
|
||||
visible[vi - 1]
|
||||
} else {
|
||||
if vi + 1 >= visible.len() {
|
||||
return;
|
||||
}
|
||||
visible[vi + 1]
|
||||
};
|
||||
let list = prefs.tab_mut(tab);
|
||||
let (Some(a), Some(b)) = (
|
||||
list.iter().position(|p| p.id == section),
|
||||
list.iter().position(|p| p.id == target),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
list.swap(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn reset_tab(tab_key: &str) {
|
||||
mutate(tab_key, |prefs, tab| prefs.reset_tab(tab));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_section_id_has_a_label() {
|
||||
// A missing arm would not compile; this pins the lookup itself.
|
||||
for id in DiscoverPrefs::available_ids(DiscoveryTab::Home) {
|
||||
assert!(!label_for(id).is_empty());
|
||||
}
|
||||
for id in DiscoverPrefs::available_ids(DiscoveryTab::ForYou) {
|
||||
assert!(!label_for(id).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_json_for_an_unknown_tab_is_empty_not_broken() {
|
||||
let doc = rows_json("recommendations");
|
||||
assert!(doc.contains("\"rows\":[]"));
|
||||
assert!(doc.contains("\"total\":0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
//! Filter-by-genre controller — Slint-free port of
|
||||
//! `crates/qbz/src/genre_filter.rs`.
|
||||
//!
|
||||
//! Owns the parent-genre list, the lazily loaded 3-level child tree, and the
|
||||
//! genre SELECTION. The selection is **per context** ("discover" for the
|
||||
//! three Discover tabs, "library-all" for the Library > All mixed feed), so
|
||||
//! the two surfaces filter independently; the shared popup edits whichever
|
||||
//! context was passed to `open()`. Persisted to
|
||||
//! `<data-dir>/qbz/genre_filter.json` when "Remember selection" is on —
|
||||
//! the SAME path and the SAME JSON shape the Slint app uses, so a profile is
|
||||
//! interchangeable between the two frontends (no new store invented).
|
||||
//!
|
||||
//! Transport: ONE JSON document on the bridge (`genreFilterJson`), parsed by
|
||||
//! `qml/controls/GenreFilterPopup.qml`. It carries the popup model (chips /
|
||||
//! tree / flags) AND, per context, the selected genre NAMES (+ descendants)
|
||||
//! so a client-side consumer (Library > All) can filter in QML JS without a
|
||||
//! second round trip.
|
||||
//!
|
||||
//! Consumers:
|
||||
//! - "discover" -> `genre_ids()` feeds `get_discover_index(genre_ids)`
|
||||
//! (`home_qt::load_home`); a change re-fetches the index.
|
||||
//! - "library-all" -> `names.library-all` feeds the QML feed derive
|
||||
//! (`library_all.rs::derive`'s genre arm, 1:1).
|
||||
//!
|
||||
//! POC-NOTEs vs the Slint controller:
|
||||
//! - The parent list loads LAZILY (first popup open) instead of at shell
|
||||
//! entry: the port has no shell-entry hook it may edit. The persisted
|
||||
//! SELECTION is read eagerly (no network), so a remembered filter applies
|
||||
//! to the very first discover fetch exactly like Slint.
|
||||
//! - The "favorites" context does not exist here: the Slint FavoritesView
|
||||
//! draws its genre button only on the Library "All" tab
|
||||
//! (FavoritesView.slint:864, `context: "library-all"`), and so does this
|
||||
//! port. The per-context store handles it the day a favorites button lands.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct GenreItem {
|
||||
id: u64,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// On-disk shape — IDENTICAL to the Slint `Persisted` (same file), so the
|
||||
/// two frontends read each other's selection.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Persisted {
|
||||
/// Per-context selections ("discover" / "library-all" / ...).
|
||||
#[serde(default)]
|
||||
contexts: HashMap<String, Vec<u64>>,
|
||||
/// Legacy single-list selection — migrated into the "discover" context.
|
||||
#[serde(default)]
|
||||
selected: Vec<u64>,
|
||||
#[serde(default = "default_true")]
|
||||
remember: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
struct State {
|
||||
/// The persisted selection has been read (one-shot, no network).
|
||||
hydrated: bool,
|
||||
parents: Vec<GenreItem>,
|
||||
/// Lazily loaded children, keyed by parent id (levels 2 and 3).
|
||||
children: HashMap<u64, Vec<GenreItem>>,
|
||||
/// Selected genre ids per context.
|
||||
selected: HashMap<String, Vec<u64>>,
|
||||
/// The context the popup is currently editing.
|
||||
current: String,
|
||||
expanded: HashSet<u64>,
|
||||
search: String,
|
||||
remember: bool,
|
||||
advanced: bool,
|
||||
loading: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn cur_mut(&mut self) -> &mut Vec<u64> {
|
||||
let key = self.current.clone();
|
||||
self.selected.entry(key).or_default()
|
||||
}
|
||||
fn is_selected(&self, id: u64) -> bool {
|
||||
self.selected
|
||||
.get(&self.current)
|
||||
.map(|v| v.contains(&id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
fn count_for(&self, ctx: &str) -> usize {
|
||||
self.selected.get(ctx).map(|v| v.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| {
|
||||
Mutex::new(State {
|
||||
hydrated: false,
|
||||
parents: Vec::new(),
|
||||
children: HashMap::new(),
|
||||
selected: HashMap::new(),
|
||||
current: "discover".to_string(),
|
||||
expanded: HashSet::new(),
|
||||
search: String::new(),
|
||||
remember: true,
|
||||
advanced: false,
|
||||
loading: false,
|
||||
})
|
||||
});
|
||||
|
||||
/// The contexts this port draws a genre button for. Both are published in
|
||||
/// `counts` / `names` so each button shows ITS OWN badge regardless of which
|
||||
/// context the popup last edited.
|
||||
const CONTEXTS: [&str; 2] = ["discover", "library-all"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence (same file + shape as the Slint controller)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("genre_filter.json"))
|
||||
}
|
||||
|
||||
fn load_persisted() -> Persisted {
|
||||
let Some(path) = store_path() else {
|
||||
return Persisted::default();
|
||||
};
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Persisted::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_persisted(contexts: &HashMap<String, Vec<u64>>, remember: bool) {
|
||||
let Some(path) = store_path() else {
|
||||
return;
|
||||
};
|
||||
if !remember {
|
||||
// Remember off — drop any persisted selection (1:1 Slint).
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return;
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let data = Persisted {
|
||||
contexts: contexts.clone(),
|
||||
selected: Vec::new(),
|
||||
remember,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&data) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the persisted selection into `s` exactly once. Network-free, so it
|
||||
/// is safe to call from the first `load_home` — a remembered filter applies
|
||||
/// to the very first discover fetch.
|
||||
fn hydrate(s: &mut State) {
|
||||
if s.hydrated {
|
||||
return;
|
||||
}
|
||||
s.hydrated = true;
|
||||
let persisted = load_persisted();
|
||||
let mut contexts = persisted.contexts;
|
||||
// Migrate a legacy flat selection into the discover context.
|
||||
if contexts.is_empty() && !persisted.selected.is_empty() {
|
||||
contexts.insert("discover".to_string(), persisted.selected);
|
||||
}
|
||||
s.selected = contexts;
|
||||
s.remember = persisted.remember;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read API (consumers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The RAW genre selection for `ctx` (no expansion, no ancestor mapping):
|
||||
/// the exact ids the user toggled, parent or sub-genre. This is what goes to
|
||||
/// /discover/* in `genre_ids` — Qobuz honors sub-genre ids server-side
|
||||
/// (1:1 Tauri/Slint; narrowing to the ancestor silently widened the filter).
|
||||
pub fn selected_ids_for(ctx: &str) -> Vec<u64> {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
hydrate(&mut s);
|
||||
s.selected.get(ctx).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The discover selection as the `Option<Vec<u64>>` the discover endpoints
|
||||
/// take (None = no filter) — `home_qt::load_home`'s parameter.
|
||||
pub fn discover_genre_ids() -> Option<Vec<u64>> {
|
||||
let ids = selected_ids_for("discover");
|
||||
(!ids.is_empty()).then_some(ids)
|
||||
}
|
||||
|
||||
fn collect_descendants(children: &HashMap<u64, Vec<GenreItem>>, id: u64, out: &mut HashSet<u64>) {
|
||||
if let Some(kids) = children.get(&id) {
|
||||
for kid in kids {
|
||||
if out.insert(kid.id) {
|
||||
collect_descendants(children, kid.id, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected genre NAMES (+ descendant names) for `ctx` — the client-side
|
||||
/// album/track genre filter used by Library > All.
|
||||
fn selected_names(s: &State, ctx: &str) -> Vec<String> {
|
||||
let mut ids: HashSet<u64> = HashSet::new();
|
||||
if let Some(sel) = s.selected.get(ctx) {
|
||||
for id in sel {
|
||||
ids.insert(*id);
|
||||
collect_descendants(&s.children, *id, &mut ids);
|
||||
}
|
||||
}
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
if let Some(g) = s.parents.iter().find(|g| g.id == id) {
|
||||
names.push(g.name.clone());
|
||||
} else if let Some(g) = s.children.values().flatten().find(|g| g.id == id) {
|
||||
names.push(g.name.clone());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The published document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChipDoc {
|
||||
id: String,
|
||||
name: String,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TreeRowDoc {
|
||||
id: String,
|
||||
name: String,
|
||||
level: i32,
|
||||
selected: bool,
|
||||
expanded: bool,
|
||||
#[serde(rename = "hasChildren")]
|
||||
has_children: bool,
|
||||
count: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FilterDoc {
|
||||
context: String,
|
||||
advanced: bool,
|
||||
remember: bool,
|
||||
loading: bool,
|
||||
#[serde(rename = "searchQuery")]
|
||||
search_query: String,
|
||||
/// Selection size of the CURRENT context (the popup's footer + header).
|
||||
#[serde(rename = "selectedCount")]
|
||||
selected_count: i32,
|
||||
/// Per-context selection sizes — each surface's button badge.
|
||||
counts: HashMap<String, i32>,
|
||||
/// Per-context selected names (+ descendants) for client-side filters.
|
||||
names: HashMap<String, Vec<String>>,
|
||||
genres: Vec<ChipDoc>,
|
||||
tree: Vec<TreeRowDoc>,
|
||||
}
|
||||
|
||||
fn tree_row(item: &GenreItem, level: i32, s: &State) -> TreeRowDoc {
|
||||
let loaded = s.children.get(&item.id);
|
||||
let count = loaded.map(|c| c.len()).unwrap_or(0);
|
||||
// Parents always have children; deeper levels show an expand arrow
|
||||
// optimistically until a load proves them empty (1:1 Slint).
|
||||
let has_children = if level == 0 {
|
||||
true
|
||||
} else if level == 1 {
|
||||
count > 0 || loaded.is_none()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
TreeRowDoc {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.clone(),
|
||||
level,
|
||||
selected: s.is_selected(item.id),
|
||||
expanded: s.expanded.contains(&item.id),
|
||||
has_children,
|
||||
count: count as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the genre tree into the currently-visible rows. With a search
|
||||
/// query, a flat list of every loaded genre matching it (expansion ignored,
|
||||
/// no chevrons); otherwise per-node expansion down three levels.
|
||||
fn build_tree_rows(s: &State) -> Vec<TreeRowDoc> {
|
||||
let query = s.search.trim().to_lowercase();
|
||||
let mut rows: Vec<TreeRowDoc> = Vec::new();
|
||||
|
||||
if !query.is_empty() {
|
||||
let matches = |g: &GenreItem| g.name.to_lowercase().contains(&query);
|
||||
let flat_row = |g: &GenreItem| {
|
||||
let mut row = tree_row(g, 0, s);
|
||||
row.has_children = false;
|
||||
row
|
||||
};
|
||||
for p in &s.parents {
|
||||
if matches(p) {
|
||||
rows.push(flat_row(p));
|
||||
}
|
||||
}
|
||||
for kids in s.children.values() {
|
||||
for k in kids {
|
||||
if matches(k) {
|
||||
rows.push(flat_row(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
for parent in &s.parents {
|
||||
rows.push(tree_row(parent, 0, s));
|
||||
if !s.expanded.contains(&parent.id) {
|
||||
continue;
|
||||
}
|
||||
let Some(children) = s.children.get(&parent.id) else {
|
||||
continue;
|
||||
};
|
||||
for child in children {
|
||||
rows.push(tree_row(child, 1, s));
|
||||
if !s.expanded.contains(&child.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(grandchildren) = s.children.get(&child.id) {
|
||||
for gc in grandchildren {
|
||||
rows.push(tree_row(gc, 2, s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn build_doc(s: &State) -> String {
|
||||
let genres: Vec<ChipDoc> = s
|
||||
.parents
|
||||
.iter()
|
||||
.map(|g| ChipDoc {
|
||||
id: g.id.to_string(),
|
||||
name: g.name.clone(),
|
||||
selected: s.is_selected(g.id),
|
||||
})
|
||||
.collect();
|
||||
let mut counts: HashMap<String, i32> = HashMap::new();
|
||||
let mut names: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for ctx in CONTEXTS {
|
||||
counts.insert(ctx.to_string(), s.count_for(ctx) as i32);
|
||||
names.insert(ctx.to_string(), selected_names(s, ctx));
|
||||
}
|
||||
let doc = FilterDoc {
|
||||
context: s.current.clone(),
|
||||
advanced: s.advanced,
|
||||
remember: s.remember,
|
||||
loading: s.loading,
|
||||
search_query: s.search.clone(),
|
||||
selected_count: s.count_for(&s.current) as i32,
|
||||
counts,
|
||||
names,
|
||||
genres,
|
||||
tree: build_tree_rows(s),
|
||||
};
|
||||
serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// The document as of right now — the bridge's construction-time seed, so a
|
||||
/// remembered selection colors both buttons before the first publish.
|
||||
pub(crate) fn current_json() -> String {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return "{}".to_string();
|
||||
};
|
||||
hydrate(&mut s);
|
||||
build_doc(&s)
|
||||
}
|
||||
|
||||
fn publish() {
|
||||
let json = current_json();
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut().set_genre_filter_json(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network loads (worker thread)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fetch_genres(parent_id: Option<u64>) -> Vec<GenreItem> {
|
||||
// Bound, not inlined: the Arc must outlive the await, and a temporary in
|
||||
// the match scrutinee is a needless lifetime puzzle inside a future.
|
||||
let runtime = crate::app();
|
||||
match runtime.core().get_genres(parent_id).await {
|
||||
Ok(list) => list
|
||||
.into_iter()
|
||||
.map(|g| GenreItem {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-qt] genre filter: get_genres({parent_id:?}) failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the parent genres once. The persisted selection is NOT validated
|
||||
/// against them — it may reference child genres that are not loaded yet
|
||||
/// (advanced view), so validating would wrongly drop them (1:1 Slint).
|
||||
async fn load_parents() {
|
||||
let mut parents = fetch_genres(None).await;
|
||||
parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
if let Ok(mut s) = STATE.lock() {
|
||||
s.parents = parents;
|
||||
s.loading = false;
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
/// Load one genre level (children of `parent_id`). No-op if already loaded.
|
||||
async fn load_children(parent_id: u64) {
|
||||
let already = STATE
|
||||
.lock()
|
||||
.map(|s| s.children.contains_key(&parent_id))
|
||||
.unwrap_or(true);
|
||||
if already {
|
||||
return;
|
||||
}
|
||||
let kids = fetch_genres(Some(parent_id)).await;
|
||||
if let Ok(mut s) = STATE.lock() {
|
||||
s.children.insert(parent_id, kids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Children + grandchildren of `id`, so `selected_names` covers the whole
|
||||
/// subtree (Library > All) and the tree shows counts.
|
||||
async fn load_descendants(id: u64) {
|
||||
load_children(id).await;
|
||||
let kids: Vec<u64> = STATE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.children.get(&id).map(|k| k.iter().map(|c| c.id).collect()))
|
||||
.unwrap_or_default();
|
||||
for kid in kids {
|
||||
load_children(kid).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every parent's children (level 2), so the advanced tree shows child
|
||||
/// counts up front. Grandchildren stay lazy.
|
||||
async fn load_all_parent_children() {
|
||||
let parents: Vec<u64> = STATE
|
||||
.lock()
|
||||
.map(|s| s.parents.iter().map(|p| p.id).collect())
|
||||
.unwrap_or_default();
|
||||
for parent_id in parents {
|
||||
load_children(parent_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions (bridge invokables)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A selection change re-fetches the discover index; the Library > All feed
|
||||
/// re-derives in QML off the republished `names`, so it needs nothing here
|
||||
/// (1:1 with the Slint split: server-side for discover, client-side for
|
||||
/// library-all).
|
||||
fn after_selection_change(ctx: &str) {
|
||||
if ctx == "discover" {
|
||||
crate::reload_home();
|
||||
}
|
||||
}
|
||||
|
||||
/// Warm the genre tree once a live session exists: the parent list (so the
|
||||
/// popup opens instantly) plus the descendants of every persisted selection.
|
||||
/// The second half matters — the Library > All filter matches on genre
|
||||
/// NAMES, and a remembered SUB-genre only resolves to a name once its branch
|
||||
/// is loaded, so without this a remembered selection would color the button
|
||||
/// and filter nothing until the popup was opened.
|
||||
///
|
||||
/// Called from `home_qt::load_home`: the first point in this port that runs
|
||||
/// with a session (the Slint app warms the same state at shell entry, a hook
|
||||
/// this package may not edit).
|
||||
pub(crate) fn warm_up() {
|
||||
let needed = {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
hydrate(&mut s);
|
||||
let need = s.parents.is_empty() && !s.loading;
|
||||
if need {
|
||||
s.loading = true;
|
||||
}
|
||||
need
|
||||
};
|
||||
if !needed {
|
||||
return;
|
||||
}
|
||||
crate::spawn(async {
|
||||
load_parents().await;
|
||||
let ids: Vec<u64> = STATE
|
||||
.lock()
|
||||
.map(|s| {
|
||||
let mut all: Vec<u64> = s.selected.values().flatten().copied().collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
all
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
for id in ids {
|
||||
load_descendants(id).await;
|
||||
}
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/// Popup opened for a surface: switch the edited context, publish, and kick
|
||||
/// the one-shot parent load if it has not run yet.
|
||||
pub(crate) fn open(ctx: &str) {
|
||||
let needs_parents = {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
hydrate(&mut s);
|
||||
s.current = ctx.to_string();
|
||||
s.selected.entry(ctx.to_string()).or_default();
|
||||
s.search.clear();
|
||||
let need = s.parents.is_empty() && !s.loading;
|
||||
if need {
|
||||
s.loading = true;
|
||||
}
|
||||
need
|
||||
};
|
||||
publish();
|
||||
if needs_parents {
|
||||
crate::spawn(async {
|
||||
load_parents().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle(id_str: &str) {
|
||||
let Ok(id) = id_str.parse::<u64>() else {
|
||||
return;
|
||||
};
|
||||
let (ctx, was_selected) = {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
hydrate(&mut s);
|
||||
let was = s.is_selected(id);
|
||||
{
|
||||
let sel = s.cur_mut();
|
||||
if let Some(pos) = sel.iter().position(|x| *x == id) {
|
||||
sel.remove(pos);
|
||||
} else {
|
||||
sel.push(id);
|
||||
}
|
||||
}
|
||||
let (contexts, remember) = (s.selected.clone(), s.remember);
|
||||
save_persisted(&contexts, remember);
|
||||
(s.current.clone(), was)
|
||||
};
|
||||
publish();
|
||||
// Newly selected: eager-load its descendants so the NAME filter covers
|
||||
// the sub-genres and the tree shows counts (1:1 Slint).
|
||||
if !was_selected {
|
||||
crate::spawn(async move {
|
||||
load_descendants(id).await;
|
||||
publish();
|
||||
});
|
||||
}
|
||||
after_selection_change(&ctx);
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_expand(id_str: &str) {
|
||||
let Ok(id) = id_str.parse::<u64>() else {
|
||||
return;
|
||||
};
|
||||
let (now_expanded, needs_children) = {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
let expanded = if s.expanded.contains(&id) {
|
||||
s.expanded.remove(&id);
|
||||
false
|
||||
} else {
|
||||
s.expanded.insert(id);
|
||||
true
|
||||
};
|
||||
(expanded, expanded && !s.children.contains_key(&id))
|
||||
};
|
||||
publish();
|
||||
if now_expanded && needs_children {
|
||||
crate::spawn(async move {
|
||||
load_children(id).await;
|
||||
publish();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_search(query: &str) {
|
||||
if let Ok(mut s) = STATE.lock() {
|
||||
s.search = query.to_string();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
pub(crate) fn clear() {
|
||||
let ctx = {
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
hydrate(&mut s);
|
||||
s.cur_mut().clear();
|
||||
let (contexts, remember) = (s.selected.clone(), s.remember);
|
||||
save_persisted(&contexts, remember);
|
||||
s.current.clone()
|
||||
};
|
||||
publish();
|
||||
after_selection_change(&ctx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_remember(remember: bool) {
|
||||
{
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
hydrate(&mut s);
|
||||
s.remember = remember;
|
||||
let contexts = s.selected.clone();
|
||||
save_persisted(&contexts, remember);
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
pub(crate) fn set_advanced(advanced: bool) {
|
||||
{
|
||||
let Ok(mut s) = STATE.lock() else {
|
||||
return;
|
||||
};
|
||||
s.advanced = advanced;
|
||||
if !advanced {
|
||||
s.search.clear();
|
||||
}
|
||||
}
|
||||
publish();
|
||||
// First time the advanced view opens, eager-load every parent's children
|
||||
// so the tree shows child counts (1:1 Slint).
|
||||
if advanced {
|
||||
crate::spawn(async {
|
||||
load_all_parent_children().await;
|
||||
publish();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(id: u64, name: &str) -> GenreItem {
|
||||
GenreItem {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> State {
|
||||
let mut children: HashMap<u64, Vec<GenreItem>> = HashMap::new();
|
||||
children.insert(1, vec![item(10, "Bebop"), item(11, "Cool Jazz")]);
|
||||
children.insert(10, vec![item(100, "Hard Bop")]);
|
||||
let mut selected: HashMap<String, Vec<u64>> = HashMap::new();
|
||||
selected.insert("discover".to_string(), vec![1]);
|
||||
selected.insert("library-all".to_string(), vec![11]);
|
||||
State {
|
||||
hydrated: true,
|
||||
parents: vec![item(1, "Jazz"), item(2, "Rock")],
|
||||
children,
|
||||
selected,
|
||||
current: "discover".to_string(),
|
||||
expanded: HashSet::new(),
|
||||
search: String::new(),
|
||||
remember: true,
|
||||
advanced: false,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_expand_to_the_whole_subtree() {
|
||||
let s = fixture();
|
||||
let mut got = selected_names(&s, "discover");
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["Bebop", "Cool Jazz", "Hard Bop", "Jazz"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contexts_are_independent() {
|
||||
let s = fixture();
|
||||
assert_eq!(s.count_for("discover"), 1);
|
||||
assert_eq!(s.count_for("library-all"), 1);
|
||||
let names = selected_names(&s, "library-all");
|
||||
assert_eq!(names, vec!["Cool Jazz"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_rows_honor_expansion_and_search() {
|
||||
let mut s = fixture();
|
||||
// Collapsed: parents only.
|
||||
assert_eq!(build_tree_rows(&s).len(), 2);
|
||||
// Expanded parent: + its two children.
|
||||
s.expanded.insert(1);
|
||||
let rows = build_tree_rows(&s);
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert_eq!(rows[1].level, 1);
|
||||
assert!(rows[0].has_children);
|
||||
// Search is flat and chevron-less.
|
||||
s.search = "bop".to_string();
|
||||
let rows = build_tree_rows(&s);
|
||||
assert_eq!(rows.len(), 2); // Bebop + Hard Bop
|
||||
assert!(rows.iter().all(|r| !r.has_children));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chip_selection_follows_the_current_context() {
|
||||
let mut s = fixture();
|
||||
let doc = build_doc(&s);
|
||||
assert!(doc.contains("\"selectedCount\":1"));
|
||||
// Jazz (id 1) is selected in discover, not in library-all.
|
||||
assert!(s.is_selected(1));
|
||||
s.current = "library-all".to_string();
|
||||
assert!(!s.is_selected(1));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@
|
||||
//! `publish_sections` for why not QVariantList-of-QVariantMap.
|
||||
//!
|
||||
//! POC-NOTEs (skipped vs the Slint controller):
|
||||
//! - Genre filter: OUT OF SCOPE — `get_discover_index(None)` (the
|
||||
//! no-filter default).
|
||||
//! - Artist/album blacklist filtering (T8): the blacklist store is not
|
||||
//! opened in this POC (phase-1 skip), so no rows are dropped.
|
||||
//! - Recently-played rails (local play-history store): OUT OF SCOPE — the
|
||||
@@ -31,8 +29,9 @@
|
||||
//! descriptor arms, prefs-driven order); the progressive per-branch lazy
|
||||
//! load is simplified to the single discover-index fetch.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::FrontendAdapter;
|
||||
use qbz_models::{
|
||||
@@ -352,10 +351,9 @@ fn order_by_prefs(
|
||||
gated
|
||||
}
|
||||
|
||||
/// The fixed render order (prefs out of scope — see module docs). Mirrors
|
||||
/// the default-enabled Home order for the shared sections, then the
|
||||
/// optional rails whose data is fetched anyway.
|
||||
fn load_prefs() -> qbz_app::settings::discover_prefs::DiscoverPrefs {
|
||||
/// The persisted per-tab section prefs (order + visibility) — the store the
|
||||
/// Discover configurator mutates (`discover_config_qt`).
|
||||
pub(crate) fn load_prefs() -> qbz_app::settings::discover_prefs::DiscoverPrefs {
|
||||
crate::sidebar_qt::user_dir()
|
||||
.and_then(|dir| {
|
||||
qbz_app::settings::discover_prefs::DiscoverPrefsStore::new_at(&dir).ok()
|
||||
@@ -364,16 +362,136 @@ fn load_prefs() -> qbz_app::settings::discover_prefs::DiscoverPrefs {
|
||||
.unwrap_or_else(qbz_app::settings::discover_prefs::default_prefs)
|
||||
}
|
||||
|
||||
/// Fetch the discover index (UNFILTERED — genre filter out of scope) and
|
||||
/// the personalized rails concurrently (mirrors home.rs's `join!`), then
|
||||
/// map everything into plain rows.
|
||||
/// Last fetched candidate set, kept so a configurator mutation (show/hide,
|
||||
/// reorder, reset) re-renders the three tabs INSTANTLY from cache instead of
|
||||
/// re-hitting /discover/index — the Slint configurator re-renders from its
|
||||
/// own section cache the same way (`home::rerender_active_tab`).
|
||||
static CANDIDATES: Mutex<Vec<HomeSection>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Assemble the three tab render lists from a candidate set + prefs.
|
||||
fn assemble(
|
||||
candidates: &[HomeSection],
|
||||
prefs: &qbz_app::settings::discover_prefs::DiscoverPrefs,
|
||||
) -> DiscoverSections {
|
||||
use qbz_app::settings::discover_prefs::DiscoveryTab;
|
||||
let home = order_by_prefs(candidates, prefs, DiscoveryTab::Home, "mostStreamed", true);
|
||||
let editor = order_by_prefs(candidates, prefs, DiscoveryTab::EditorPicks, "mostStreamed#album", false);
|
||||
// For You: the local-history arms (recentPlaceholder) self-hide on
|
||||
// empty data in Slint — drop them here (local store out of scope).
|
||||
let for_you: Vec<HomeSection> = order_by_prefs(candidates, prefs, DiscoveryTab::ForYou, "mostStreamed", false)
|
||||
.into_iter()
|
||||
.filter(|s| s.kind != "recentPlaceholder")
|
||||
.collect();
|
||||
DiscoverSections {
|
||||
home,
|
||||
editor,
|
||||
for_you,
|
||||
}
|
||||
}
|
||||
|
||||
/// The SET of section ids the POC can actually RENDER for a tab — the
|
||||
/// configurator's row filter (the row ORDER comes from the prefs, not from
|
||||
/// here). A pref entry whose rail this port
|
||||
/// never builds (radio / spotlight / reco-engine rows, see the module docs)
|
||||
/// is dropped instead of listed as a toggle that changes nothing. Returns
|
||||
/// EMPTY before the first fetch (nothing is known to render yet) — the
|
||||
/// caller then falls back to the full pref list so the modal is never blank.
|
||||
pub(crate) fn renderable_ids(tab: qbz_app::settings::discover_prefs::DiscoveryTab) -> Vec<String> {
|
||||
use qbz_app::settings::discover_prefs::DiscoveryTab;
|
||||
let Ok(candidates) = CANDIDATES.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if candidates.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let variant = if matches!(tab, DiscoveryTab::EditorPicks) {
|
||||
"mostStreamed#album"
|
||||
} else {
|
||||
"mostStreamed"
|
||||
};
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for section in candidates.iter() {
|
||||
// The For You tab drops the local-history placeholders (see assemble).
|
||||
if matches!(tab, DiscoveryTab::ForYou) && section.kind == "recentPlaceholder" {
|
||||
continue;
|
||||
}
|
||||
if section.id == variant {
|
||||
out.push("mostStreamed".to_string());
|
||||
} else if !section.id.contains('#') {
|
||||
out.push(section.id.clone());
|
||||
}
|
||||
}
|
||||
// Both "mostStreamed" candidates map to the SAME pref id, so the Editor
|
||||
// pass can emit it twice. The caller only does membership tests, so the
|
||||
// set is sorted + deduped rather than kept in candidate order.
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Serialize + push the three tab documents onto the home bridge.
|
||||
pub(crate) fn publish(sections: &DiscoverSections) {
|
||||
let home_json = serde_json::to_string(§ions.home).unwrap_or_else(|_| "[]".to_string());
|
||||
let editor_json = serde_json::to_string(§ions.editor).unwrap_or_else(|_| "[]".to_string());
|
||||
let for_you_json = serde_json::to_string(§ions.for_you).unwrap_or_else(|_| "[]".to_string());
|
||||
crate::home_bridge::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_home_sections_json(QString::from(home_json.as_str()));
|
||||
b.as_mut()
|
||||
.set_editor_sections_json(QString::from(editor_json.as_str()));
|
||||
b.as_mut()
|
||||
.set_for_you_sections_json(QString::from(for_you_json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-render + republish the three tabs from the CACHED candidates and the
|
||||
/// freshly persisted prefs. The configurator's post-mutation hook: no
|
||||
/// network, so a toggle / reorder lands on the next frame. A section that
|
||||
/// was disabled at fetch time may still have uncached covers — those
|
||||
/// download in the background and trigger one more publish, exactly like the
|
||||
/// initial load.
|
||||
pub(crate) fn republish_from_prefs() {
|
||||
let candidates = match CANDIDATES.lock() {
|
||||
Ok(c) if !c.is_empty() => c.clone(),
|
||||
// Nothing fetched yet — the next load_home publishes with the new prefs.
|
||||
_ => return,
|
||||
};
|
||||
let mut sections = assemble(&candidates, &load_prefs());
|
||||
let mut missing = crate::artwork_qt::attach_cached(&mut sections.home);
|
||||
missing.extend(crate::artwork_qt::attach_cached(&mut sections.editor));
|
||||
missing.extend(crate::artwork_qt::attach_cached(&mut sections.for_you));
|
||||
missing.dedup();
|
||||
publish(§ions);
|
||||
if !missing.is_empty() {
|
||||
crate::spawn(async move {
|
||||
crate::artwork_qt::download_missing(missing).await;
|
||||
let mut sections = sections;
|
||||
let _ = crate::artwork_qt::attach_cached(&mut sections.home);
|
||||
let _ = crate::artwork_qt::attach_cached(&mut sections.editor);
|
||||
let _ = crate::artwork_qt::attach_cached(&mut sections.for_you);
|
||||
publish(§ions);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the discover index — honoring the shared "discover" genre selection
|
||||
/// (`genre_filter_qt`), 1:1 with the Slint `current_genre_filter()` — and the
|
||||
/// personalized rails concurrently (mirrors home.rs's `join!`), then map
|
||||
/// everything into plain rows.
|
||||
pub async fn load_home<A>(runtime: &Arc<AppRuntime<A>>) -> Result<DiscoverSections, String>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
use qbz_app::settings::discover_prefs::DiscoveryTab;
|
||||
// The RAW genre selection (parent OR sub-genre ids, exactly as toggled)
|
||||
// goes straight to /discover/index — Qobuz facets sub-genre ids
|
||||
// server-side; mapping them up to an ancestor silently widens the filter.
|
||||
let genre_ids = crate::genre_filter_qt::discover_genre_ids();
|
||||
// First point in this port that runs with a live session: load the genre
|
||||
// list in the background so the popup opens instantly and a REMEMBERED
|
||||
// Library > All selection resolves to names (see warm_up's docs).
|
||||
crate::genre_filter_qt::warm_up();
|
||||
let (response, favorite_albums, release_watch, top_artists) = tokio::join!(
|
||||
runtime.core().get_discover_index(None),
|
||||
runtime.core().get_discover_index(genre_ids),
|
||||
favorite_album_cards(runtime),
|
||||
fetch_release_watch(runtime),
|
||||
top_artist_cards(runtime),
|
||||
@@ -391,20 +509,11 @@ where
|
||||
release_watch,
|
||||
top_artists,
|
||||
);
|
||||
let prefs = load_prefs();
|
||||
let home = order_by_prefs(&candidates, &prefs, DiscoveryTab::Home, "mostStreamed", true);
|
||||
let editor = order_by_prefs(&candidates, &prefs, DiscoveryTab::EditorPicks, "mostStreamed#album", false);
|
||||
// For You: the local-history arms (recentPlaceholder) self-hide on
|
||||
// empty data in Slint — drop them here (local store out of scope).
|
||||
let for_you: Vec<HomeSection> = order_by_prefs(&candidates, &prefs, DiscoveryTab::ForYou, "mostStreamed", false)
|
||||
.into_iter()
|
||||
.filter(|s| s.kind != "recentPlaceholder")
|
||||
.collect();
|
||||
Ok(DiscoverSections {
|
||||
home,
|
||||
editor,
|
||||
for_you,
|
||||
})
|
||||
let sections = assemble(&candidates, &load_prefs());
|
||||
if let Ok(mut cache) = CANDIDATES.lock() {
|
||||
*cache = candidates;
|
||||
}
|
||||
Ok(sections)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +32,8 @@ mod album_bridge;
|
||||
mod artist_bridge;
|
||||
mod artwork_qt;
|
||||
mod bridge;
|
||||
mod discover_config_qt;
|
||||
mod genre_filter_qt;
|
||||
mod home_qt;
|
||||
mod library_db_qt;
|
||||
mod library_qt;
|
||||
|
||||