77e9feccffdda8f2ae29695a48c9dd810bb74bc7
74
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
77e9feccff |
feat(qt): album header — 260px cover, no divider rule, a gradient that lands mid-toolbar
Four owner-requested changes to the Qobuz album page, validated by smoke. 1. THE DIVIDER RULE goes invisible rather than away. The gradient band sizes itself off that item's `y` and the whole column below is positioned after it, so deleting the item would move the page; only the paint was unwanted. 2. COVER 224 -> 260. Now one constant: the literal was repeated at five sites — the real header, its skeleton twin, and the two text columns that measure `parent.width - 224 - 32`. Changing only the header would leave the skeleton at 224 and make the page jump 36px when the real one replaces it. The header Row has no explicit height, so it grows on its own. The URL tier is unchanged (`best()`, per the 2026-08-16 ruling that no existing surface is re-tiered DOWN); what decodes is RoundedImage's scaled derivative at the drawn size, so 260 over 224 is ~35% of a small image. 3. THE ATMOSPHERE REACHES MID-TOOLBAR — the 8px spacer below the divider plus half the 52px toolbar band — so it dies across the quality chip and the search box instead of stopping at the divider. A named constant rather than a `mapToItem` chain: that chain crosses three nested containers and goes stale when an ancestor moves without notifying. 4. THE FALLOFF IS PROGRESSIVE. The tint held FULL strength to 0.82 and died inside the last 18%, drawing a visible seam — and extending the band made it worse, not better, because the falloff is a FRACTION of the height: the solid part grew while the fade stayed as short. The page-surface overlay underneath started at 0.82 too, putting a second seam on the same line. Both now decay over the bottom ~60% on an ease-out curve, stepped through explicit alphas because a GradientStop interpolates linearly between the two colours it is given. cc |
||
|
|
c02e7713d9 |
perf(qt): stop republishing the Recommendations document for artwork
Covers that resolved after the document was published forced a second
`publish_snapshot`, and a republish hands QML a brand-new array:
`QQuickItemView::setModel()` then resets each rail's scroll offset and tears
down its QQmlDelegateModel. So every landing batch rebuilt all nine rails'
delegates, which is what a screen capture of a cold Recommendations load
shows — the rows visibly reassembling while covers trickle in, filling out of
order both between rows and within a row.
The fix is the pattern this port already applies twice: covers travel as a
url-keyed PATCH and the document is left alone. `foryou_qt::emit_foryou_art`
is the precedent for the For You rails, and `myqbz_bridge` cites it verbatim
for MyQBZ; the reco rails were the remaining holdout.
The download path is deliberately untouched. This changes only how a resolved
path is ANNOUNCED, never what is fetched or cached, so every other consumer of
the shared image cache — the now-playing bar, the queue, the `recent` rails —
sees exactly what it saw before.
Two traps this had to close, both already paid for once on the For You side:
* `sectionArtOf()` falls back to `item.artPath`. `recoArt` only ever holds
Recommendations urls, so for a Home / Editor's Picks / For You card the
lookup misses and the binding returns what it always returned. No surface
that reads a baked `artPath` can lose its cover to this — by construction,
not by care. The pinned rail, fed by its own store, is untouched.
* `anyItemArtPending` now consults the map. A patched cover never gets its
`artPath` back through a republish, so the bare "artUrl set and artPath
empty" predicate would be true forever and pin the 900ms skeleton pulse
Timer on.
`refreshRecoArt()` on mount is the third: HomeView is destroyed on every
navigation while this document is published once per session, so a fresh mount
must be re-handed what already resolved or the covers come back blank.
Weekly's two rails still republish their own narrower property; their blast
radius is two rails, not nine, and widening this change was not worth it.
Verified by owner smoke: the three checks (nothing else lost its covers, the
rails stop reassembling, covers survive navigating away and back) all pass.
cc
|
||
|
|
101d0e25d4 |
perf(qt): one rebuild per query in the local grid, and reveal covers in order
Two changes to the local albums grid, both BUILT BUT UNSMOKED — Local Library is best-effort for the next release, so this is committed to preserve it rather than because it was validated. 1. ONE REBUILD PER QUERY. `rows` and `groups` are two bindings over one query: the host derives `albumsVisible` from the search box and `albumsGrouped` FROM `albumsVisible` (LocalLibraryView.qml:378-380). With a handler each, a single keystroke rebuilt twice, and the first of the two was WRONG as well as wasted — it chunked the new rows against the previous groups. Turning grouping off did not save it either, because `groupRows` returns a fresh `[]` every time and a new array is still a change. Coalesced onto a zero-interval Timer: one rebuild per event-loop turn, reading both new values. `Component.onCompleted` stays synchronous so a mount does not show a frame of empty grid. `[coltiming]` logs every rebuild with its cost, so a smoke run can falsify the coalescing: one line per keystroke means it holds, two means it does not. Timed around the work, never from a deferred callback. 2. ORDERED COVER REVEAL. The cold-thumbnail pool starts work in window order but finishes out of order, since decode cost varies per cover, so covers appeared scattered across the grid. Ordering the emission in Rust would have made it worse: one slow decode at index 3 would hold 4..N behind it, adding latency instead of hiding it. Instead arrivals are applied behind a front that walks the window at a fixed rate; a cover landing behind the front is applied at once. The front advances every tick unconditionally, which is what makes a stall impossible, and a key with no ordinal is released immediately. Note for whoever picks this up: this reveal covers the LOCAL path only. The uneven fill visible in ordinary navigation is HomeView's For You rails, which take a different route (`forYouArt` via `onForyouArtReady`). cc |
||
|
|
ed081cac6b |
fix(qt): put the local albums scrollbar and A-Z strip on the real edge
LocalAlbumsTab pads its content 32px on the right, and both the scrollbar and the jump strip lived inside that padding — so the bar sat 32px from the window edge (52 with the strip up) while LibraryView.qml:1281 puts the same control at 4, or 34 to clear its strip. Side by side the local grid's bar reads as floating away from the edge. The collection now takes two measurements, both from the WINDOW's right edge: `scrollBarInset` (where the bar should land) and `hostRightInset` (how far the component's own edge already is from it). The bar's margin is their difference, which goes negative and overhangs — safe, because the component root is a plain Item; the `clip: true` in that file is on the ListView, a sibling. Both default to 0, so LocalArtistsTab and LocalFoldersTab, which have not opted in, are untouched. The A-Z strip moves out to 12 from the window edge, matching LibraryView.qml:1269. cc |
||
|
|
c0fe8a80d3 |
fix(qt): silence the TypeError flood the constant-cell grid introduced
Regression from the previous commit's first fix. Pinning the inner Repeater
to `root.cols` is what made recycling free, but it also means the tail slots
of a partly-filled row are now real, live cells with no album behind them.
The delegate anticipated that — `modelData` resolves to null and the cell
sets `visible: false` — and that is not enough: in QML `visible: false` stops
an item PAINTING, it does not stop its bindings EVALUATING. Every
`cardCell.modelData.id`, `.title`, `.artKey` on an empty slot therefore threw
once per evaluation, which during a scroll is a continuous log flood (the
same ten line numbers, over and over, seen in a smoke run).
`modelData` keeps its meaning — "is this slot filled?" — so `visible` still
works. The bindings now read `cardCell.slot`, which is the same object when
the slot is filled and a frozen all-empty object when it is not.
`artWanted("")` is false and `artPathOf("")` is "", so an empty slot stays
silent through the skeleton's probe arm too.
Deliberately NOT a Loader gated on the data: `active` would flip every time a
full row recycled into a partial one, rebuilding the AlbumCard — precisely
the construction churn the constant cell count exists to remove.
cc
|
||
|
|
bd9aec1440 |
perf(qt): make the local albums grid actually recycle, and stop it asking
for the whole library
The owner's report is precise and is what this targets: the grid OPENS
fine; scroll, scrollbar drag and search are what feel sluggish, and "the
first two keystrokes hurt" — the ones where the filter still matches
hundreds. That shape is a model swap, not a slow filter.
UNSMOKED. Committed to save progress; no binary matches this tree.
1. FIXED CELL COUNT. `reuseItems` was already on the ListView and bought
nothing, because the pool recycles the row SHELL while the inner
`Repeater { model: modelData.items }` got a new array on every recycle
and rebuilt its AlbumCards anyway. The card is where the cost is: ~29
items, a RoundedImage with TWO render targets (its own layer plus a
separate mask layer), image probes, a skeleton. The shell was reused
and the expensive part was not. The Repeater now runs over `root.cols`
— a constant — so cells survive recycling and a scrolled or refiltered
row only reassigns `item`.
2. THE HEADER WINDOW BUG. `loIdx` fell back to 0 whenever the first
visible entry was a group header, because headers carried no `base`.
In grouped mode, scrolling with a header at the top of the viewport
asked `queueWindowReport` for artwork from album 0 through the current
letter — hundreds of covers dispatched and later evicted, per scroll.
Headers now carry the flat index their group starts at.
3. SEARCH DEBOUNCE on the albums box, which the Tracks box in the same
file already had. `albumsSearch` is not a query, it is the filter
input: writing it per keystroke refilters 1267 albums, rebuilds the
entry list and hands the ListView a new model, tearing down every
delegate. Note the tradeoff — this ADDS 200ms before any work, so the
box may feel more deliberate even as it stutters less.
Also here, from the album pages (lower priority per the owner — their
cost is paid once and the view is fine in use afterwards): the disc
header, which carried a CardMenu per TRACK, is Loader-gated; a genuinely
quadratic multi-disc scan in LocalAlbumView is hoisted out of the
per-row call; and the track rows are windowed. That windowing was
measured broken twice (a band computed against its own parent's derived
height — a dependency cycle) and the fix is unverified.
Credit where due: 1 and 2 came out of an independent review that also
judged the previous round's lazy menus and `reuseItems` "mostly beside
the main point". That is a fair call.
cc
|
||
|
|
47a7641fc2 |
fix(qt): bound the queue panels, and make the gate catch what it missed
Follow-up to
|
||
|
|
0ed9db2f3f |
fix(qt): stop the Weekly rows rebuilding the whole Recommendations tab
Reported as "it loads, then it flashes and loads again", and only on that tab. Measured: the cached blob paints at t+0 and the two Weekly rows land at t+0.9s — and that is with BOTH of their per-week caches hitting, so the delay is ListenBrainz's playlist listing, not the tracks. Their arrival called publish_snapshot, which republishes the document, which hands QML a brand-new array; QQuickItemView::setModel() then tears down every rail's QQmlDelegateModel and rebuilds it. Nine rails destroyed and rebuilt to deliver two, a second after the tab had already settled. Not the artwork, which was the obvious suspect: the same run logged "0 artwork misses". Split them out the way HomeView already splits pinned / radio / spotlight, for the same reason and with the same shape: the document carries an ordering slot, the rows ride reco_weekly_json, and rebinding that re-creates only those two delegates. Every other rail's sectionData resolves to the same modelData reference, so a Weekly landing re-evaluates nine bindings and rebuilds two. The slots are emitted unconditionally (a new `slot()` helper) rather than self-hiding on emptiness like `section()` does — a slot that vanished while empty and reappeared when filled would change the document, which is the republish being avoided. QML hides an empty one instead, on the gate the other out-of-document rails already use. Weekly covers are attached in the same split, so a late cover republishes that property rather than the document: two rails, not nine. cc |
||
|
|
19836547d9 |
feat(qt): give Recommendations its cache window and Refresh now
The gear was dead on that tab and the cache window was unreachable, so whatever hours value happened to be in the shared prefs was permanent. It reads 24h on this account; the reference offers 24/36/48/72 and defaults to 48. Only the WRITER was missing. `reco_cache_ttl_secs()` already read `reco_cache_ttl_hours` from the shared per-user discover prefs, and `refresh()` was already written — and carrying `#[allow(dead_code)]` because nothing called it. TTL_HOURS is the reference's table verbatim (discover_prefs.rs:100). That matters because the value lands in the file BOTH builds read: if the two disagree on the index mapping, a window chosen in one reads back as a different one in the other. The QML option order is the other half of the same contract, noted at both ends. The setter republishes the RESOLVED index rather than the one it was handed, so a failed write (no per-user store) snaps the select back to what is actually stored instead of showing a window nobody saved. The index is seeded at shell entry next to publish_settings(), the first point where the per-user directory is known — without it the select would open on the store's 48h default and misreport anyone who chose otherwise. The modal now has two modes on the same six blocks the reference gates (DiscoverConfigModal.slint:187/213/221/250/261/326): section tabs keep the list, banner, count and reset footer; Recommendations hides those and shows the explainer, the window select and Refresh now. Its empty-state line is hidden there too — DiscoveryTab has no "recommendations" arm, so an empty row list is by design and "No sections to configure yet" would be a permanent lie. All seven strings already existed translated in all eight catalogs. Two comments removed on the way: both claimed the external reco engine was "not ported" and justified the disabled gear with it. It has been ported for a long time (recommendations_qt.rs, 745 lines) — the fossil was the only thing still keeping the setting out of reach. cc |
||
|
|
aa2f0362d9 |
perf(qt): stop Library building the body it is not showing
The grid and the list are siblings, both anchored to fill the content
area, and both bound to `visibleRows` — and `visible: false` stops a
QQuickItemView from painting, not from refilling. Measured with a
per-view creation counter: on Albums the grid built 51 delegates and
the hidden list built 47 beside it; on Tracks the list built 47 and the
hidden GRID built 51, the expensive direction since a FeedGridCell
carries a cover. A mount went 368ms -> 216ms, to-idle 727ms -> 441ms.
The predicates above them already claimed this ("one predicate per
body, so no two can mount at once"); they only ever governed painting.
Gated by collapsing the height rather than by swapping the model: this
file spends a paragraph on why handing `model` a new value is harmful
(QQuickItemView::setModel resets the scroll offset and rebuilds every
delegate — the "a heart click in All throws me back to the top" bug),
and the height gate leaves `model` untouched.
Also: the derive and the feed parse are now timed under the shared
qbz.nav.timing category, deferred through Qt.callLater. The defer is
required, not tidiness — `visibleRows` is evaluated during creation,
and a LoggingCategory used before the component completes never gets
its defaultLogLevel applied, which Qt warns about and which left the
logging ON for every user. The parse log was previously a bare
console.log doing exactly that.
Measured for the record: the derive is NOT the cost. Over a 354-item
feed it runs in 0-13ms, so the O(n log n) toLowerCase in its sort
comparator is a real but currently theoretical problem.
cc
|
||
|
|
a4c29c796c |
perf(qt): build the tab that is showing, not all of them
Entering Discover froze the UI thread for 1.52s — not slow, frozen:
zero pixels changing anywhere in the window — where the Slint build
painted the same page, artwork included, in a single frame. Measured
frame by frame from a screen recording, with the click located at the
moment the nav flyout disappears.
`visible: false` hides an item; it does not stop QML from building it.
HomeView declared its four Discover tabs as sibling Columns gated only
on `visible:`, so every mount instantiated all four and 19 of its 28
section rails belonged to tabs nobody was looking at. Gating each body
on a Loader's `active:` cut a Home mount from 426-512ms to 182-190ms,
and the tab block itself from 364-444ms to 134-140ms.
Synchronous, deliberately: incubation is time-sliced, so `asynchronous`
spreads the same work over more frames instead of removing it.
Three more of the same class, found by auditing for it:
- SlimGrid's delegate Loader had no `active:`, so it built a full
card for the items past `total` and then hid them.
- LocalLibraryView mounted all four tab bodies, the Tracks one being
the 16K-row view.
- AlbumInfoModal built a credits row per track of the album even when
the Description tab was showing.
ContentRouter's header claimed the fix was to keep views alive across
navigation. It is not, and the references say so: Slint destroys its
view tree with `if` (AppShell.slint:426) and Tauri removes it from the
DOM (+page.svelte:6286), and Tauri even refetches from the network on
mount while still transitioning instantly. Neither gets its speed from
caching the view — both keep the mount proportional to one viewport.
The rule is the SIZE of a mount, not its lifetime. Paragraph rewritten
to say that.
cc
|
||
|
|
e779b6ec1e |
fix(qt): a pulled track's menu offers nothing that leads nowhere
Smoke found the row rendering correctly and the menu still offering four entries that cannot work on it. Add to mixtape and Add to playlist are refused, in both row implementations. The precedent was already there -- `hasPlaylistAddSeam` excluded a broken local ref for the same reason -- this is the same idea with a different cause. Gated on `pulled` rather than `pulledDead`, so a downloaded copy is refused too: what a playlist or mixtape stores is the Qobuz catalog id, which is dead on every other device, every other surface and for anyone the list is shared with. The local copy playing here does not make the row safe to seed elsewhere. Both share links go too. Qobuz's page for a withdrawn track is gone and Song.link resolves through it, so each one rendered and led nowhere. Track info as well: it needs the catalog record the pull took away. That empties the menu on a surface that does not offer the repair -- an album page, search results -- so the row menu no longer opens when its model comes back empty. An empty popup reads as a broken control rather than as "there is nothing to do with this row". The ArtistView fork keeps its go-to entries and can never reach that state. cc |
||
|
|
7780efa5db |
feat(qt): mark tracks Qobuz pulled, and keep them out of the queue
Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier, sometimes it republishes the same recording under a new album id. The row keeps appearing and simply cannot play. Until now the user found out by clicking it. The app already knew and threw the answer away: eight builders hardcoded `streamable: true`, so the API's own flag never reached a row model. The reactive half was already here and stays -- a terminal failure still toasts and skips, bounded at five consecutive, with the 403 back-off kept separate so a 403 storm cannot burn five good tracks. Detection has two clauses. The API's `streamable: false`, and a session-only memory of tracks that died terminally under the player this run: /album/get is honest, but plenty of rows only fail at the stream-url step, and that memory is what stops the queue re-offering them. Session-only on purpose -- the reference kept the equivalent set in localStorage, never expired it and never revalidated it, so a track Qobuz restored stayed dead forever. Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag, favourite and download, and STAY visible and right-clickable: hiding them would defeat the point and remove the only route to a replacement. Qobuz's own player does exactly this, and the pulled track carries displayable:false while still being rendered -- that flag is not a hide instruction. A downloaded copy is exempt everywhere. The offline tier resolves before any availability question, so a pulled track already on disk plays fine; dropping it from the queue would take away something the user downloaded, and it would bite hardest offline. Those rows say they are playing the local copy and keep their play affordance. Two seams filter, not one, and both had to learn the surviving anchor: every caller read `first_id` from the UNFILTERED list, so the seam dropped that row and the app then asked the player for the id it had just discarded -- silence, no toast. That was already reachable today through the artist blacklist. The seams now return the surviving start and its id. Two id bugs surfaced on the way and are fixed here. A playlist row's `playlist_track_id` was filled with the CATALOG id; they are different numbers for the same row, one naming the recording and the other its slot in that playlist, and Qobuz's delete and reposition verbs both take the membership id -- so "remove from playlist" was addressing a row that does not exist. And the For You rows were built with `..default()`, claiming availability while the queue built from the same track read the truth. Catalogs: the two strings this needed already existed in all seven translated locales and are reused; the skipped-tracks toast is a plural pair, and the replacement feature's strings ride along here since they share the files. cc |
||
|
|
5d1448b23e |
feat(qt): pin the award page's controls in a nav bar
Search, shuffle and the award picker are one right-aligned cluster in a pinned 44px bar now, given the ArtistView jump-nav treatment: its own fill, a 1px bottom hairline, the page's 32px rhythm, and a slot in the flow that reserves its height so the hero does not jump when the bar is lifted out. Order inside the cluster is left-to-right from "narrow this" to "leave here": the search and the shuffle act on the page you are on, the picker takes you off it. Carries the rounded-corner rule this codebase has hit twice already (ArtistView.qml:2795, the Discover full-bleed toolbar): pinned at the pane's top edge the bar's square corners poke out past the shell's rounded bezel, so the top corners round — but only WHILE it is at the top, driven off the live y. Mid-page the bar is surrounded by content and a notch there is worse than the square corner it fixes. The picker still tracks the OPEN award: `currentIndex` is the document's `catalogIndex`, so navigating to another award moves the selection with you instead of stranding it on the one you came from. cc |
||
|
|
2d33a4fdfb |
feat(qt): search, load more and random on the award landing page
Three controls so the landing is usable without bouncing through "See all". Search sits in the section header and shares the LISTING's query — one award, one filter — so what you type here is still in force behind See all. Client-side over the loaded set, like the listing and like the reference. Load more is a BUTTON, not infinite scroll. The listing sub-view owns the scroll-driven paging; this page has the "Other awards" rail under the grid, and auto-loading would push that rail out of reach forever. Suppressed while a query is active, for the listing's reason: paging in rows the filter then hides is work with no visible result. That button needed the preview cap gone. The landing document published `take(PREVIEW_PAGE_SIZE)`, so "load more" would have fetched rows the view threw away. The first fetch still asks for one preview page, so the first paint costs what it did before; the cap is now only on the REQUEST, not on what the document carries. Random plays one album out of the LOADED set (the LibraryToolbar precedent: pick a visible row, `QbzPlayer.playAlbum`). Not the whole award — the rest is not here to pick from, and reaching for it would put a blocking fetch behind a button that should feel instant. Every mutation republishes BOTH documents now: they share one accumulating album list, so a page loaded or a query typed on either side is visible on the other. No new msgids — "Search albums & tracks…", "Load more" and "Loading…" are all in the eight catalogs already. cc |
||
|
|
1f636679d5 |
fix(qt): "Play all next" enqueues the artist instead of replacing the queue
The artist ⋯ menu offers "Play all next", "Add all to queue" and "Shuffle all". The first was wired to `playArtistTop(false)` — which the bridge's own doc calls "Play all": it REPLACES the queue and starts playing. There is no plain "Play all" row in that menu, so there was no way to get the intended behaviour, and asking to queue an artist next wiped whatever was queued. The invokable takes a mode now and both rows route through the shared insertion funnel: "next" inserts at the cursor, "queue" appends. That is what the reference does (`enqueue_artist_top_selected(.., next: true)`) and what `playback_qt::enqueue_artist_top_mode` was written for — it had no caller at all, which is how the miswiring survived. cc |
||
|
|
dc2496e5c0 |
feat(qt): award pages, reachable from the album sidebar and from each other
Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine line, follow heart, "Acclaimed Releases" with its count and See All, the preview grid and the "Other awards" rail) and the paginated listing with its client-side search. Routes "award" and "awardalbums", the label pair's shape — one controller for both, since they share the hero, the follow state and the identity. The three rules from the Tauri review are in the module header because they are the ones a re-implementation gets wrong: the grid is ALWAYS /award/getAlbums (/award/page is user-scoped and may omit releases, so it feeds the hero and nothing else); the favorite split is plural-read, singular-write, because the backend derives `award_ids` from the write kind; and getAlbums has no has_more, its `total` is a hint, so the only sound predicate is `total > loaded`. The album sidebar's AWARDS cards navigate now — with an id directly, and by NAME through the resolver when Qobuz omits it, which it does. They were also reading `album.awards` when the field lives on `album.header`, so the block had rendered EMPTY for every album since it was written: an empty Repeater draws nothing and reports nothing, and the sidebar's own gate counted the same empty list. The LABEL card beside it had a "no label view yet" note and no handler, years after the label view landed. Beyond the reference: an all-awards dropdown pinned to the page's top-right, so any award is reachable without first finding an album that won it. It costs nothing new — the /award/explore crawl already existed to resolve names to ids. The award icon was lucide's MICROPHONE under the wrong filename. Search had routed around it with `crown` and left a note; kiosk was missed and drew a mic for "Most popular". The asset is the laurel the other two frontends use, and kiosk now asks for the crown it wanted. cc |
||
|
|
41c1b50d61 |
feat(qt): offline cache manager, and stamp the album identity on cached tracks
The last missing Settings surface: the OFFLINE CACHE group (Open manager / Open folder / Clear all) and the manager view behind it — stats bar with the GB limit, sort and failed-only toolbar, A-Z artist rail, album+track rows with per-row re-download and remove. The engine was already here; what was missing were four actions (remove_album, redownload_track, open_folder, clear_all), the rollup and the view. Two bugs the owner's smoke exposed, both older than this view: ALBUM IDENTITY WAS BEING LOST ON EVERY ALBUM DOWNLOAD. A Track inside an /album/get payload carries no nested album object, and `track_cache_info` read exactly that, so `cache_album` wrote `album = NULL, album_id = NULL` for every track it queued. Measured on a real index: 34 of 46 rows, three complete albums. Not cosmetic — `album_id` is the key `remove_album`, `redownload_album` and the downloaded-album registry look rows up by, so those albums could not be removed, refreshed or recognised as downloaded. The album document now stamps id and title onto every track that lacks its own. The reference has the identical bug. AND THE ROLLUP MIXED ARTISTS. Every row without an album_id collapsed into one "__singles__" bucket titled from the first track and credited to its artist, so three albums by three artists rendered as ONE album header while two of the artists vanished from the rail. The key is a ladder now: album id, else (artist, title), else a per-ARTIST singles bucket — two artists can no longer meet in one group even with the broken data. Rows already on disk recover their title from library.db, which does know it, so an existing cache reads correctly without re-downloading. Deltas from the reference, both because Qt is not Slint: covers are paths (slint::Image is not Send, which is why it pre-decodes; Qt has no such constraint) and selection is QML-local, this port's convention, which keeps the document a pure function of the DB so a finishing download cannot fight the user's checkboxes. Clear-all confirms first — the reference fires straight from the glyph, and undoing this one means re-downloading the whole cache. cc |
||
|
|
9899ad6b09 |
feat(qt): open the playlist importer from the Playlist Manager toolbar
The importer had exactly two doors, both inside the sidebar's playlist popup, so the one view whose job is managing playlists could not reach it. Placed before "New folder": that slot disappears in flat mode and a Row skips invisible children, so a button after it would slide sideways every time the mode toggles. No offline gate — the modal recomputes its own and shows a disabled confirm with the reason, which beats a toolbar button that vanishes when the network drops. No new msgids: "Import" is already in all eight catalogs. cc |
||
|
|
16a25bbbe6 |
fix(qt): pin the Local Library tracks scrollbar to the view edge
The bar sat inside the tab's 32px content inset AND carried its own 4px margin, leaving it 36px from the window with an empty channel beside it. It now cancels the inset; the rows keep theirs. The "By name" arm is untouched: there the alpha strip owns the right edge and 22px is what clears it. The inset is a named property now because the scrollbar has to cancel it — written twice, moving one silently misaligns the other. cc |
||
|
|
13f1b106dc |
fix(qt): keep the Local Library select button clear of the search box
The expandable search keeps a 30px footprint and grows its inner field LEFT (QbzLineEdit.qml, `x: root.width - width`), so anything positioned before it in the row is covered while the search is open and cannot be clicked. On the Tracks tab the multi-select button led the row and was exactly that. Moved to the end of the group — the slot the Albums tab already puts it in, so the two tabs agree as well. Swept the other nine expandable searches: in all of them the field is already the first child of its row, and the tree rail's box width is computed to stop at the third button. Tracks was the only case. cc |
||
|
|
7d98ad044f |
feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON document per screen plus a revision counter, so QML re-parses even when the text is byte-identical. - The list: two tabs with per-type counters, a server-side search on a 300 ms debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort, direction, two filters and grid/list; Tracks gets group and the filters only. Building the Albums toolbar on both would have invented two controls. - The detail: a format dropdown whose selection re-scopes every downloaded mark, the progress section and Add-to-Library together; a determinate bar counted in TRACKS, never bytes; goodies as an album-level action counted separately. - The album download is the frontend loop, not the Rust command the reference registered and never called. Sequential, concurrency 1, cancellable, and a failed track does not abort the album. - Both prefs are read at runtime, not merely written. `show_purchases` is the master gate and `nav_tb_purchases` only relocates the entry to the title bar; both default false, so Purchases still ships hidden. - Scanned local rows keep their raw `qobuz_purchase` source so the gold badge survives in list mode as well as grid, while still classifying under the Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping them would badge every purchase the user merely OWNS as locally downloaded. Defects fixed after the screens were written, none of which any test could have caught: - `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every publish is a silent no-op, so both screens would have stayed permanently empty with nothing logged. The cause was an ownership gap rather than the code: `Main.qml` belonged to no lane because the plan listed the files each lane CREATES rather than the files it TOUCHES, and "belongs to nobody" is indistinguishable from "done" until something runs. - A second download of the same album nested inside the first. The store rewrites its destination to the album subfolder after the first successful track so that Add-to-Library adds the album rather than the whole music root, and the download planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`, one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are now separate fields with names that cannot be confused, with a regression test. - A tab counter could stick at zero forever with its list plainly full: the two ids calls each swallow their error, and the reference's backfill from the list response was missing. - Cancelling made the whole progress block vanish instead of showing the cancelled state, whose msgid was already translated in all eight catalogs. - Add-to-Library re-walked the entire library when the folder id it needed was already in hand. - A live language switch and a logout left the previous state behind. Deliberate divergences from the contract, each recorded with its reason: the page header is a plain `fontSection` title with no icon tile, because nothing else in this application announces itself over a large coloured square; purchase download status travels inside the album document rather than on the shared cache channel, which is keyed by track id alone and would let the two features overwrite each other on the same row. Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p qbz-qt` 398 passing, offscreen smoke green with zero QML complaints. Not verified, and cannot be from here: every populated screen. This account owns nothing, so only the empty states have ever rendered. cc |
||
|
|
8bd02e35d3 |
feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.
ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
no onClicked, and a RoundedImage with no fit:, so the default crop blew an
800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
reference already has (Add/Change/Remove image, Open in browser, Save as…) and
the lightbox this port invented for albums and playlists. Custom images are
keyed by artist NAME into the same custom_artwork.json the Slint build writes,
so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
artwork_qt::cached_path consults the override FIRST, so coverMap held the
custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
is not: it leaves the percent-escapes in place, so any path holding % # or ?
came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
"View cover", since en carries none of that family and falls back to the msgid.
CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
their expansion into the full 55-value palette and the on-disk format all stay
in the shared qbz-theme crate, so a theme authored in the shipping Slint build
already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
because current_slug() re-reads it from disk — a seed taken afterwards would
resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
memoized for the life of the process and filled at bridge construction, so a
process that booted without the file would keep serving the OLED default even
after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
every drag frame with a truncating write, on a file the other build has open.
ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
a section plus a TOC entry, bold and backticks are stripped rather than styled.
Text.MarkdownText is a different subset and would have rendered a different
document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
to bump.
DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
carries about seventy, so every issue filed from a Qt build arrived with no
kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
sample-rate state and no playback context. The report is injected INTO
qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
not mapped, so their runtime column can only read "—", and their saved column
would come from a graphics.json this binary never reads or writes — foreign
state presented as this app's configuration.
SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
on declaration order and carried no z, so any z-bearing modal buried them: a
toast raised by a background event behind an opaque panel is never seen. They
now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
always-dirty, so the whole build script — per-shader qsb, the recursive asset
sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.
Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.
Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/
cc
|
||
|
|
016eb3cd23 |
feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
|
||
|
|
fda56f9ea2 |
fix(qt): pin the artist jump nav bar and round it at the pane edge
The strip scrolled away with the body; the reference clamps it to the top. Lifted out of the flow with a slot holding its place, using the reference's clamp verbatim -- the formula must not add the viewport y or the scroll is double-counted and the bar lifts ahead of the body mid-transition. Pinned at the pane's top edge it then showed the square-corner defect already fixed in Discover: Qt's clip is a rectangular scissor that ignores radius, and the shell hides its bezel nubs while the dynamic background shows through the corners, so a full-bleed child has to round itself. Driven off the live y, so the rounding exists only while the bar is actually at the top -- rounded corners in mid-page would be a notch with nothing behind them. cc |
||
|
|
c2944db973 |
feat(qt): source-aware playback, session persistence and loading affordances
One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.
Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.
Session persistence, both levels the port was missing:
- The startup-page preference is finally read. It had been rendering and
persisting a value nothing consumed. `last_view` is written in the SHARED
ui_prefs vocabulary, not this port's route ids -- four of the six differ,
and writing the raw ids would have silently broken the other frontend's
restore. `on_session_entered` recorded "home" unconditionally, which also
rewrote last_view on every boot, so the setting could not survive its own
startup.
- Queue and active track survive a restart. session_persist was tied to a
frontend by exactly one type alias, so it moved to qbz-app generic over the
adapter rather than being copied. Verified end to end: the snapshot restored
here had been written by the Slint binary, and the exit flush keeps an
un-consumed resume position instead of overwriting it with the engine's
idle 0.
Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.
Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.
cc
|
||
|
|
3b03682e09 |
fix(qt): pane corners stay round under the dynamic background
AppShell hides its BezelCorner nubs while the field is meant to show
through the pane's corners, and Qt's clip is a rectangular scissor that
ignores radius — so any full-bleed child at y=0 squares the pane. The
doctrine was already written in AppShell.qml ("a full-bleed pane child
must round itself; there is no mask that can do it for it here"); these
eight surfaces predated it or, in the two thin bars added with the
ambient port, ignored it.
- The seven fixed 56px headers (Home, Search, DiscoverBrowse,
ArtistReleases, PlaylistBrowse, PlaylistManager, PlayHistory) take
topLeftRadius/topRightRadius. Invisible with the background off, where
the view root paints the same colour beneath. PlaylistBrowse's SECOND
bar is the category strip at mid-page and keeps square corners;
PlayHistory keeps its unconditional opaque fill, which is reference
parity (:43/:34) and a separate question from the corner.
- LocalAlbumView's neutral header band is now gated on the background,
1:1 with the Qobuz page's atmosphere (AlbumView headerAtmoOn,
AlbumPageView.slint:168): the two dark backdrops clash and the field
already provides one. That band had been square since it was written;
the nubs had been hiding it, and they only stand down when the field
is showing. Header text there is theme.textPrimary, not the Qobuz
page's light-on-dark hdrStrong, so it does not depend on the band to
stay readable — checked before removing the backdrop from under it.
Gate: cargo build -p qbz-qt, both QML audits, offscreen smoke 0
complaints.
cc
|
||
|
|
b136c9cf5d |
feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc), uncommitted until now: the app-wide dynamic background in both modes (AmbientField with GPU/Canvas arms, AppShell layering, ambient palette pipeline), the wave-1 clip/batch sweep across the tree (alpha batches 449 -> 327, reproducible), the rounded-cover and icon-tint arms, and scripts/qt-batches.sh (batches + presents/s sampler). The GPU behaviour of these pieces was finalized in the follow-up pulse commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/ GPU-COST-INVESTIGATION.md for the full campaign. km |
||
|
|
79e3cdc46b |
feat(qt): multi-select bulk bar on every Qobuz track listing
The album-page pattern (selection in QML, actions in Rust) generalized: - New shared engine bulk_tracks_qt.rs: one catalog batch-resolve that preserves the selection's visible order, then queue / play-next / play-later / add-to-playlist / add-to-mixtape / add-to-favorites / make-offline, with the queue context stamped per host. - PlaylistView: toggle in the header action row, bar inline above the column header, selection resets when the playlist changes. - ArtistView: the Popular Tracks header's dimmed toggle goes live; one selection covers Popular Tracks + Appears On + the in-library rows; the PopularTrackRow fork gets the select checkbox AND its offline cell/menu block stop being inert stubs. - LabelView: Popular Tracks gets the same treatment. - AlbumView: the bar moves from a floating overlay to the inline spot the Local Library album view uses (bar in flow, content below). km |
||
|
|
d112c726b6 |
fix(qt): custom album covers propagate to every surface
The per-builder sweep was whack-a-mole — the report caught empty cards in Home > Recently Played Albums and the NPB, and mosaics still showing the Qobuz art. Overrides are keyed by album id but most surfaces carry only the art URL, so the link is now the artwork HASH: every size variant of one Qobuz cover shares its filename stem. Setting a cover records hash -> path in a Qt-side store (custom_cover_keys.json — same reason as the playlist store: the Slint file round-trips known keys only), the album page backfills it for covers set before the map existed, and artwork_qt's cached_path / cached_raw_path answer the override for any URL whose hash has one — cards, NPB, MPRIS, queue, collage tiles, no per-surface wiring. download_missing skips overridden URLs. km |
||
|
|
a53e8a7af7 |
feat(qt): custom covers for playlists + the app-wide custom-art sweep
Two halves of one feature: - Playlist custom covers (Qt-first; no Slint counterpart yet): the playlist header cover gets the cover menu (Add/Change/Remove) and the lightbox on left-click. The store is a NEW file, custom_playlist_covers.json — a `playlists` key inside the shared custom_artwork.json would be silently dropped the next time the Slint app wrote an override (its store round-trips only known keys). The override beats the playlist's own Qobuz graphic AND the member-cover mosaic on the header, the sidebar rows, the cards and the library grid. - Album custom covers now apply everywhere, not just the album header: doc builders route an album's art URL through cover_artwork_qt::prefer_album_cover (the override's path flows through the ordinary artwork pipeline — artwork_qt::classify's LocalFile arm resolves it synchronously, no QML change). Swept: album page carousels (more-from-artist / suggestions / Last.fm similar), artist page releases, search result cards, and the playlist mosaics (a member album with a custom cover contributes THAT art to the collage tiles). km |
||
|
|
83b8bd8890 |
feat(qt): offline downloads + multi-select on the album page
Two AlbumView parity features sharing the row machinery: OFFLINE CACHE (the menu rows and the per-track state the report was missing): - offline_qt.rs activates the per-user OfflineCacheState on login / session-restore / offline-session (one fan-out point in bind_per_user_stores) and tears it down on logout — same on-disk store as the other frontends, so copies made in Slint/Tauri show here and play here. - playback_qt's play funnel now passes the active cache + the row sink to play_track_resolved (it passed None, None: the offline tier was unreachable even for downloaded tracks). - offline_cache_qt.rs ports the action set: cache_track / cache_tracks / cache_album / redownload_album / remove_cached / refresh_cached, with toasts and a bridge-signal row sink (trackCacheStatusChanged). - TrackRow's download column is live: cloud / spinner / accent check / red alert per status (0/1/2/3/4), click map ready->remove else download, and the context menu's offline block (Make available offline / Refresh / Remove). Status is seeded per row from the session cached-id set (album/artist/label/for-you docs) and pushed live over the signal. - Album ⋯ menu: "Make available offline" swaps to "Refresh offline copy" on the live all-cached aggregate. MULTI-SELECT: the toolbar toggle is live; selection state stays in QML (LibraryView precedent), rows check off in select mode, the bulk bar carries the reference's full inventory — select-all, play-next, play-later, queue, add-to-playlist, add-to-mixtape/collection, Add to Library, make-offline, clear — and the Ctrl+A/Escape router duck-types the view's selectAll/exitMultiSelectMode like the other tabbed views. Still out (documented in place): the Offline Manager view, the row padlock on unlock, per-disc actions, playlist-level cache button. km |
||
|
|
704fd7e60a |
fix(qt): artist Latest-release card renders its data again
The card bound `artist.lastRelease.*` unqualified, and inside an AlbumCard instantiation the bare name `artist` resolves to the CARD'S OWN string property — a string has no .lastRelease, so every binding fell back to "" and the card rendered as an empty tile (section visible, no art, no title) while the grids' modelData-driven cards were fine. Caught live on a VNC-driven session: the published document carries the full lastRelease object; only the QML scope shadowing ate it. Every read in the block is now root.artist.lastRelease. km |
||
|
|
af95ce164a |
fix(qt): search top-result track hero no longer invades the Artists rail + crown icon
Two Most-popular regressions on the search results page: - When the top result is a TRACK, the hero painted from its slot's center ~100px into the Artists carousel: SearchTrackHero's root Rectangle had no size, and a bare Item's implicit size is 0x0 (it does not size from children), so the slot's horizontalCenter anchor pushed the whole card sideways. The artist/album arms carry explicit sizes and never shifted. The hero now declares its 200px width explicitly. - The header glyph was "award", which reads as a mic. The reference uses the crown (SearchResultsView.slint:584); crown.svg is ported into the icon masters (primary + the warning bake) and the header uses it. Both verified live on a VNC-driven session with the exact "all things" search from the report. km |
||
|
|
d9b1c26ff3 |
feat(qt): album page parity — cover menu + lightbox, header actions, album info modal
Four wired surfaces on AlbumView, all previously inert stubs: - Cover menu (right-click, AlbumPageView.slint:300-370): Add/Change/Remove custom cover over the SAME custom_artwork.json store the Slint app uses (a cover set in one app shows in both), Open in browser, Save as…. Plus the NEW lightbox (menu entry + left-click): the art at its best available quality fitted to the window, custom override preferred. - Header action row: radio (the existing foryou_qt album-radio seam), booklet (visible when a PDF goody exists; downloads via save-as — no in-app reader, like the reference), and add-to-mixtape/collection — the MyQBZ picker's Accepts engine already lets albums target Collections (the Tauri rule), so the button just feeds it. - Album Info modal (the extensive one): fresh /album/get → credits grouped through the shared qbz_qobuz::performers parser, per-track copyright, review tab when the album has editorial text, label link, musician navigation, per-track play in album context. Tauri layout (left fixed meta column, right scrollable tabs) per the project ruling for this feature; the header's label link is wired along the way (LabelView has existed for a while — the POC-NOTE was stale). - … menu: Add to playlist (the loaded view's track ids into the picker), Add to mixtape, Share Qobuz link, Share Album.link (UPC -> Deezer -> album.link, #514 path — the resolver half of share.rs is now ported for the album arm). The offline rows stay out: no Qt album-cache seam. i18n: "View cover" + "Could not load the image" catalogued in all seven locales. Gate: both QML audits + 305 tests + debug/release builds green. km |
||
|
|
28c04bd990 |
docs(qt): correct the stale search notes
The two whose falsifying commit does not touch their file, so R7 rule 2 could not put them beside the code. QbzEmptyState claimed it had consolidated SearchView's and Cortinilla's no-results states. Neither uses it - both draw their own line inline, and the cortinilla's is a single 28px row inside a dropdown rather than a centred block. SearchView's SOURCE note justified its Qobuz-only assumption by saying the local sections "are not ported". They are now. The assumption still holds for the results PAGE, which is Qobuz-only, so the note is narrowed rather than deleted - the cortinilla is a different document whose rows carry their own source field. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C12 (R7-7, R7-8). cc |
||
|
|
4806584ee2 |
feat(qt): local "View more" routes into the LocalLibrary tabs
The three local sections' View more links now leave the search surface entirely and land on the matching Local Library tab. Sending them to the Qobuz results page instead would show a page with no on-device content at all, which reads as the link having done nothing. local-album -> Albums tab local-artist -> Artists tab local -> Tracks tab, pre-filtered with the live query The query pre-filters the TRACKS tab only. Albums and artists have no search box of their own, so handing them a query would be a lie - the list would come back unfiltered while something claimed otherwise. The active tab is QML-owned state, so the route is a pending property the view consumes, mirroring the localPendingArtist mechanism that already existed for "go to artist" on a local album. It is released after being applied, because the property CHANGE is the trigger and an unreleased route could not fire twice in a row. The consumer also runs on mount, so a route set while the view was unmounted still lands. Setting the tracks filter writes the view's own search state as well as issuing the query, or the search box would render empty next to a filtered list. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C9 (sections 3.4-3.5). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
fa433d465b |
refactor(qt): extract search_bridge.rs from the QbzBridge god-object
QbzBridge carried five domains (Settings, Search, Playlist, Genre filter,
Discover config); its own boot() comment called the split "TEMP (phase 23)".
TRACK-RULES section 2 requires one cxx-qt bridge per domain. This moves the
Search domain out verbatim, ahead of the cortinilla parity work, so the
refactor and the behaviour change stay separately reviewable.
QbzSearch (search_bridge.rs) takes 7 properties and 11 invokables. Two
deliberate changes ride the move:
- selected_index -> cortinilla_selected_index. It is cortinilla-only
state and the bare name on an app-wide singleton is a collision hazard.
- cortinilla_scroll_y f32 -> f64, unifying with the immersive twin in
immersive_bridge.rs, which was already f64.
cortinilla_query is declared here but not yet wired; C2 wires it, which is
what makes Enter carry the text the user actually typed instead of the last
successfully loaded payload's query.
Every Rust writer of a moving property was re-pointed BY HAND, never by
regex over the multi-line ui() closures: the seven crate::ui( sites in
search_qt.rs plus two that live outside it and that the QML-only xref audit
cannot see - settings_qt.rs (the live Settings > Appearance path) and
main.rs toggle_intelligent_search. All 48 QML consumer sites across
Cortinilla.qml, HeaderBar.qml and SearchView.qml were updated, including the
one Connections block, whose onSelectedIndexChanged handler was renamed with
its property.
A stale 6.0 base in flat_index_content_y is left in place and annotated: it
mirrors a Flickable viewport margin rather than content space, so every row
reports 6px low. C4 corrects it with its unit test; this commit is a move.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C0.
Gate: build clean (no new warnings), 256/256 tests, qml_resolution_audit
214 files, qml_singleton_xref 28 bridges with QbzSearch resolving, offscreen
smoke 0 complaints + home published.
cc
|
||
|
|
5130e872e7 |
feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km |
||
|
|
ec8588687b |
feat(qt): artist page parity - load more, section sort, discography
The release-section "Load more" fetched a page and appended it to a structure
no view ever re-read. The handler pushed IN PLACE into the object behind
`readonly property var artist: JSON.parse(QbzArtist.artistJson)`, and the
releaseSections projection returned that same array on the unfiltered arm, so
both Repeaters were re-handed the identical reference and nothing was dirty.
The tree's own rule, written in cards/PlaylistCollage.qml: rebind requires a
NEW object reference. A second bug sat on the same path - the artist document
republishes progressively (stories, then each MusicBrainz section as it
resolves) and every republish re-runs the parse, wiping anything appended, so
fixing only the first would have left a bug that looks random. Both
pre-existing; the in-flight placeholder added earlier is what made an
always-silent no-op visible.
Appended pages now live in an overlay keyed by release type. It survives a
republish, emits new arrays so the Repeaters rebuild, dedupes by album id,
carries the reference's 4-page cap per bucket (artist.rs:718-720, which hands
off to the discography page beyond it), and is guarded against a page
requested on one artist landing on another's grid - releaseSectionReady
carries no artist id and every artist has an "album" bucket.
"See discography" was a pointer-cursor MouseArea with no onClicked under a
POC-NOTE saying the page was out of scope. ArtistReleasesView.qml +
artist_releases_qt.rs port ArtistReleasesView.slint: infinite scroll rather
than a button, plus the loading, error-with-retry, empty and offline arms.
The album page's "More from <artist>" view-all is a second door to the same
page. Its paging offset counts what the SERVER returned rather than the
rendered row count the reference uses: blacklist filtering and id dedupe make
the rendered count smaller, so the reference re-requests seen rows and skips
the tail whenever the blacklist is non-empty.
The per-section sort was a dimmed Rectangle with no MouseArea, under a
comment asserting no seam existed. It is now a real QbzSelect with the
reference's five options and index mapping, persisted per release type in
artist_prefs. Choosing "Default" after a sort does not restore the server
order until the page reloads - that is the reference's behaviour
(album_map.rs:262 is a bare no-op arm and resort sorts the live model), kept
deliberately rather than diverged.
Also, two visual fixes from the same round that share these files: Popular
Tracks drew its cover at radius 8 where the shared rows/TrackRow.qml uses 4,
and the album page's rail mounts said `width: parent.width` inside a Column
with 32px padding - QML padding shifts children without narrowing
parent.width, so the rails hung 32px off the right and the right page chevron
landed outside the window.
Carries scripts/qt-diag.sh, which has been sitting untracked since the
rendering round: it answers "which backend is this actually running on" from
a real graphical session, refusing ssh and refusing offscreen because either
would answer the wrong question. That refusal is the point - the doctrine it
disproved ("shader effects render nothing on this port's software path") came
from an offscreen observation, and offscreen forces software by definition.
cc
|
||
|
|
edbc037b0f |
feat(qt): Rust share seam, and fix the dead label share link
The port had no clipboard seam at all. share_qt.rs ports the URL builders and the clipboard from crates/qbz/src/share.rs:1-70. The long-lived OnceLock<Mutex<Option<Clipboard>>> is copied verbatim with its reason: arboard ties the offer's lifetime to the LAST live Clipboard object, so a create-per-copy port passes on KDE, which runs a clipboard manager that accepts the handoff, and silently loses the text everywhere else (#514). arboard carries the wayland-data-control feature at the same pin the reference uses; without it arboard is X11-only and every copy fails on a Wayland-only session. The label header's Share was not merely silent - it copied a link that does not resolve. open.qobuz.com has no /label/ route. The host is per-ENTITY, not per-app: track and album on open.qobuz.com, artist, playlist and label on play.qobuz.com. That asymmetry is why the five builders are ported as a set and why no call site formats its own URL any more: this one did, and got it wrong. QbzHome::label_share now copies play.qobuz.com/label/{id} and raises the "Link copied" toast, 1:1 with main.rs:13164-13178, and the QML Loader/TextEdit clipboard hack it replaced is deleted - its own comment claimed there was no toast seam, which stopped being true when toast_qt.rs landed. Only the URL builders and the clipboard are ported. share.rs:72-184 is the Song.link/Odesli resolution, which applies to tracks and albums; artists and labels are link-only, so porting it now would be dead async HTTP. LabelView also adopts QbzLoadMore here - its two changes share the file. cc |
||
|
|
6f4fb72f57 |
feat(qt): shared QbzLoadMore with an appended-page skeleton
Five hand-rolled "Load more" buttons existed in three visually distinct shapes. One control now carries all three arms, so the idle appearance of every site is unchanged. While a fetch is in flight it draws a QbzSkeleton composite BELOW the button - the owner asked for below - which is one animator for the whole placeholder row, with settleMs 8000 so a fetch that never returns stops shimmering instead of pulsing forever. Idle it costs nothing: the block lives behind an inactive Loader, so mounting the control changes no pixel on a page nobody has clicked. SearchView had half of this already, and the half it had was on the wrong side: a skeleton ABOVE the button, on the albums tab only. All four tabs now use the control, each with its own real cell pitch and row shape rather than the albums tab's numbers copied across. AlbumCollection gains an opt-in tail fade (armTailFade) for the arrival half of the round. It is keyed by the set of ids on screen at click time, not by an index threshold: the label page sorts its grid, and under any order but newest a fetched page interleaves, so a threshold would re-fade albums that never moved - the flicker the fade exists to avoid. It also lets grouped mode fade, where there is no flat index at all. Inert unless a host arms it, so Discover Browse and Play History are bit-for-bit unchanged. cc |
||
|
|
8776ebe82d |
fix(qt): carry the real track data into every queue, and shuffle for real
Two defect classes the owner found by playing music, both of them the port diverging from a reference that gets it right. QUEUE TRACK FIDELITY. The reference has ONE builder, make_queue_track (playback.rs:2426), and it takes the raw Track, so bit_depth and sample_rate come straight off the API model. This port forked that into ten builders and five of them mapped from a DISPLAY ROW instead — a row that carries qualityTier / qualityDetail / qualityLabel, the already-formatted strings, and no raw numbers. Those five hardcoded bit_depth: None, sample_rate: None: playlist_qt, artist_qt, label_qt, library_qt and playback_qt's feed_queue_track. The consequence reached the user as a broken NPB AudioStamp: the track edge seeds quality_state from those same numbers, so starting playback from a playlist, an artist page, a label page or a Library-feed row left TRACK_MAX at zero and the badge could not render a complete bit-depth / sample-rate line. Starting from an album was perfect, which is what made it look intermittent — the album path was one of the five that had always built from the raw Track. The rows now carry the numbers alongside the strings, because the producer derives the strings FROM those numbers and was discarding them; parsing "24-bit / 96 kHz" back into integers would have been a second source of truth. The same builders were dropping artwork_url, which is why the queue sidebar had no covers for playlist-sourced or local rows. Also fixed here, found while tracing: local_playlist_qt::play shipped context: None, so set_queue_stamped fell through to derive_context, which answers None for a many-album playlist. The song card then used its per-track album fallback and pointed at a local album_group_key instead of the playlist. Pre-existing for the header Play, but a guard added last round routed the row click through the same path, which turned it into a regression there. SHUFFLE. set_shuffle(true) only randomises what comes NEXT, so four entry points raised the mode and then played the list's #1 track: the album header, the playlist header, the local-playlist header and every local play_rows call. The one track the user actually hears on pressing Shuffle was deterministic. All four now mix their own list with the xorshift Fisher-Yates the port already uses in play_track_list_in and start at the top of the mixed order. The mode stays raised so playback continues shuffled past the list. The owner traced the flag-and-play-from-the-top shape to a very early Tauri experiment; it is not the behaviour anywhere. Not addressed, going to a separate pass: a Qobuz playlist carrying local or Plex sidecar rows. The reference routes those through the local snapshot (playback.rs:3488 is_mixed); this port has no sidecar concept on playlist_qt at all, so such a row is still typed as qobuz. Same defect class, one layer up. cc |
||
|
|
35508b5f87 |
fix(qt): the defects found smoking the playlist and My QBZ round
Un-following a Qobuz playlist left it in the sidebar and in My Library. toggle_follow called unsubscribe_playlist and settled the ownership snapshot and stopped there, so the row only left at the next full refetch. Both surfaces are patched directly now, in both directions: following from a card buys the metadata with one get_playlist rather than re-reading getUserPlaylists, which lags the write. The un-follow collapses a hearted-and-followed playlist to the one row the next load will actually produce instead of dropping it, because the local heart is a library.db flag that survives the unsubscribe — removing every row made it vanish and come back, which is the original complaint wearing the other shoe. The Plex mark on album cards was a hard-drive glyph tinted accent — a blue hard drive — and the three Qobuz kinds drew no badge at all. The badge now ports discover/AlbumCard.slint:326-354 exactly: 24x24 at radius 4 pinned bottom-right with a 6px inset, plex-logo untinted at 16, qobuz-logo at 18 (22 for a download), the purchase arm gold on its amber chip, hard-drive white at 14 for local only. The brand marks are plain Images: routing them through QbzIcon floods every pixel with the tint and flattens a logo to a silhouette. SourceIcon moves to controls/ since it is no longer local-only, and every other surface drawing a hard-drive was audited against its .slint. The local album detail loses the two nav chevrons above the art. They are a Tauri-era leftover from before the app had global navigation. My QBZ used a bespoke card in the grid. MyQbzDetailCard is deleted and the standard cards/AlbumCard is mounted with its overlay. localMode bundled four separate things — action routing, the heart, the pin and Block this album — so a multi-source container had to switch all four off together; it is split, and the catalog affordances now follow the ITEM. On a Qobuz cell the heart works through the shared favourite seam; on a track, playlist, local or Plex cell it is absent rather than dead. Row covers carry two rungs: the list keeps _50, the grid gets _600. Not the _230 that would merely cover a 200px card — the complaint was that the cover reads softer than a normal AlbumCard, and a normal AlbumCard is fed image.large, which IS _600. _200 is not a rung the CDN serves at all (it answers 404). The My QBZ grid sets reuseItems, and AlbumCard assigns to its own isFavorite and isPinned when toggled, which destroys the binding that fed them. A recycled delegate therefore showed the previous row's heart and the click added the new album while the glyph said remove. Restored with the Qt.binding idiom this port already uses on TrackCard, PlaylistCard and TrackRow. The details view no longer force-opens every row on entry, and the per-row open state persists. Library > Artists showed placeholders for nearly every portrait. The side panel never reported its artwork window — the one caller of libraryArtworkWindow is LibraryView.reportWindow, and the panel had no report call at all, so no key ever reached the resolver and artMap stayed empty for its rows. The rail now reports through that same path, with the section-header sentinel reportWindow already skips. Note the reference has no portrait fallback either: an artist Qobuz serves no image for is a placeholder on both sides. cc |
||
|
|
a16cf56c86 |
feat(qt): playlist manager, folders, importer and the Library parity round
Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc |
||
|
|
d1862e90da |
fix(qt): the Source column draws the real Qobuz and Plex marks
Owner: "si es de qobuz, icono de qobuz, si es de local (folder o plex) cada uno tiene sus svg; Slint y Tauri los tenían bien." They did. The port did not, and the reason was an asset gap rather than a styling choice: `qobuz-logo-filled.svg` and `plex-logo.svg` were never carried over from the Slint tree, so `SourceIcon` substituted `cloud-download` for every Qobuz kind and a tinted `hard-drive` for Plex — drawing "downloaded" and "local file" where the design calls for two brand marks. Both assets now live in `qml/assets/brand/`, beside the Discogs / Last.fm / MusicBrainz marks that are there for exactly the same reason. The rule this restores is one `SourceGlyph.slint:4-8` calls load-bearing: the full-colour logos render UNTINTED and only the monochrome local glyph is tinted, to MUTED text and never accent. Slint spells out why — routing a logo through a tinting primitive "would turn the logos into a flat accent silhouette, the bug this primitive replaces". `QbzIcon` floods every pixel with its tint, so the two logo arms deliberately do not go through it. `qobuz_purchase` is the one arm that must tint a multi-colour asset (the same mark in #eab308 — the colour is what says "purchased"), so it is the one arm that uses an effect, and it degrades on a software renderer to the untinted mark rather than to nothing. The two ROW call sites now pass SourceGlyph's own numbers (15px, muted) instead of inheriting the version picker's (14px, secondary) — the two Slint components this file stands in for disagree on both, which is why they are properties. Verified by driving the app: the Qobuz rows show the Qobuz mark and the Plex rows the gold Plex chevron, in their own colours, with zero "Cannot open" in the log. cc |
||
|
|
83c1aec4f5 |
feat(qt): collapsible rows in the My QBZ detail, and ephemeral leaves no trace
**The accordion the owner asked for.** Each expandable row gets a chevron; clicking it opens THAT row's tracks inline and nothing else. Verified by driving the real app: in list mode every row is collapsed with the chevron pointing right, clicking row 3 — a PLEX row, so the hybrid path — flips its chevron, renders its three tracks with durations and quality badges, and leaves rows 1, 2, 4 and 5 untouched. This is a DELIBERATE divergence from both references, decided by the owner with the facts in hand: neither Slint nor Tauri has a per-row accordion. Both render inline tracks only as a view MODE, for every row at once, and both say "no chevron" in their own comments (MixtapeDetailView.slint:1139, MixtapeCollectionDetailView.svelte:124-127). The owner first believed Tauri had it, asked for the code to be checked rather than the comment, and on the evidence chose the accordion anyway. Do not "restore parity" by removing it. Built additively on machinery that already existed: the document already carried `canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`. What was added is per-row OPEN state in Rust, a toggle invokable, and lazy per-row fetching — opening a row fetches only its tracks, once. The "expanded" view mode survives and now means "open all", sharing ONE notion of open rather than a second parallel state. Also here: - `body`'s `anchors.bottomMargin: 100` shrank the list VIEWPORT; in the .slint that 100px is padding INSIDE the Flickable, i.e. scroll runway. Measured on screen before the fix: the last row clipped mid-height with ~85px of dead band under it. - `apply_resolved` cloned and serialised the WHOLE document per resolved item (34 serialize→parse→patch cycles for one collection, 200 for a big one, on the GUI thread), and `patch_expanded` did the same while the document carried every loaded item's inline tracks — O(n²). - The DEAD affordances the audit filed against the modals and the grids. **Ephemeral honours its name** (owner rule): an ephemeral folder plays and queues, and writes nothing durable. Recently Played and Most Played are now guarded — an ephemeral album played through would previously have outranked real albums in Most Played, one row per track start. The Slint reference does NOT have this exemption; that is a gap in both frontends, not a Qt regression, and this does not restore parity by removing it. Two stores were deliberately NOT changed and need the owner's word: the scrobblers and Discord presence. Neither is "las bibliotecas" — nothing is stored on our side — but both publish the track to a third party, so if "ni now playing ni nada" is meant literally they belong in the same decision. The one-line fix location is in the lane report. cc |
||
|
|
b6cf06ff2a |
feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db opens instead of 23-34 — the local source owns a pooled handle and hands out no guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an await either. Bug 1 from the seam design is dead by construction, not by care. The owner's rules, decided today, now live in one place instead of eight: - **Collection** takes albums, singles and EPs from ANY source — qobuz, folders, Plex, and whatever is added next. **Mixtape** takes albums, playlists and tracks. This was a single boolean (`restrict_to_mixtape`) that could express neither release types nor "collections accept local". - An unsupported combination must never be VISIBLE: the picker stops offering the impossible action, and the error stays a log-only safety net. Owner: "el bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI". - **Ephemeral honours its name** — playable, queueable, and offerable to nothing else. - A row's source comes from the ROW. Eight QML sites built the add-to-mixtape payload with `"source": "qobuz"` written as a literal, so a Plex or local track was stored as Qobuz and afterwards could not play at all: the app asked the Qobuz API for an id that never existed there. `QobuzSource` reads the client through a lens on every call instead of holding a clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384, both on paths that run before `set_session`), so a cached clone goes stale and fails silently — the hazard the crate's own doc comment described. The lens is installed in `on_boot`, first of everything, and finding out why is the lesson of this commit. The gate was green — build, 142 tests, both QML audits, a clean offscreen smoke — and the feature was visibly broken: every Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE" type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever touches it first BUILDS it, and one built without the lens leaves Qobuz permanently detached. Only driving the real app surfaced it; the crate's error string named the cause exactly, which is the one thing that made it a ten-minute fix instead of an afternoon. cc |
||
|
|
ea373a601c |
fix(qt): four AUDIBLE parity rows — stream errors, seek clamp, label shuffle, Plex keys, Library queue
PARITY-DEBT #7, #15, #17, #4 and #5. - **#7** the stream error was drained and only logged, so an ALSA / exclusive failure — guaranteed inside Flatpak and Snap — froze the bar at 0:00 with no explanation. It now toasts, with the sandbox-aware rewrite the reference carries (playback.rs:5093-5115). This is the support issue that fix was written for. - **#15** seek was clamped to the full duration instead of the downloaded fraction, so the head could be dragged into bytes that do not exist. The poll loop publishes `npSeekableMax` and both bars clamp to it, matching SeekBar.slint:93-98. The cache overlay was already drawn and purely decorative. - **#17** a label page's Shuffle called `set_shuffle(true)`, turning the GLOBAL shuffle mode on and leaving it on for everything played afterwards. The reference shuffles the vector in place and never touches the mode. - **#4** a `plex:<hash>` album-boundary key was passed where a Plex rating key belongs, so the fetch 404'd and the PREVIOUS track kept playing under the new card. Guarded at both sites, 1:1 with playback.rs:668-680 (`b5c1a76e`). - **#5** clicking a track in Library played exactly one track and stopped. It now queues the whole VISIBLE list from the clicked row (`order_by_visible`). The Library row play needed one edit outside its author's lane, which the singleton xref caught before the build did: `QbzLibrary.libraryPlayVisible` was called from QML and implemented in `library_qt`, but never declared on the bridge. That is precisely the failure the xref exists for — QML resolves singleton members lazily, so a missing invokable compiles clean and throws when the user clicks. The QML had shipped a `typeof === "function"` feature-detect with a fallback, so it degraded instead of throwing; the invokable now exists and the fallback is dead code by design. cc |
||
|
|
1c2bdb749a |
fix(qt): the My QBZ detail delegates read the live row, not the model's snapshot
The whole in-place patch path was a silent no-op, and it took the Quality column and expanded mode down with it. `model: root.rows` is a JavaScript array. QQmlDelegateModel SNAPSHOTS such a model, so a delegate's `modelData` is the engine's own copy of the element, not the object sitting in `root.rows`. `syncRows` patches with `Object.assign` on purpose — reassigning the array resets contentY and throws the user to the top of a 200-item collection — and then bumps `patchRev` to re-evaluate the bindings. Every part of that worked except the last one: the bindings faithfully re-read the STALE copy. Measured before the fix, driving the real app over VNC: 34 items resolved Rust-side with correct tiers (26 get_album, all 200 OK), 34 publishes, 71 QML refreshes, `syncRows` taking the patch branch every time — and the Quality column sat on its loading dots forever, byte-identical five minutes apart. Expanded mode rendered no inline tracks at all, for exactly the same reason: `inlineTracks` is delivered by that same patch. The fix is to index the live array: `patchRev` stays the dependency and `root.rows[index]` is the object Rust actually patched. `modelData` remains the fallback for the frame where the array and the delegate model are momentarily out of step. No array reassignment, so the scroll position is still preserved. After: the badges resolve to "CD 16-bit / 44.1 kHz" and the inline track lists render with their durations and per-track badges. cc |