c11bdf0654f30a91cd6cece702e9b79b01a56b40
6261
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c11bdf0654 |
feat(qt): bring back the New playlist modal, and delete the shortcut
The sidebar "+" called create_playlist(), which made a playlist named literally "New Playlist" and navigated to it. Four of the modal's five fields had no other door, and one had NO door at all: name hardcoded reachable in Edit playlist description always empty reachable in Edit playlist folder always root reachable in the sidebar row menu public always false NOWHERE offline-only from connectivity NOWHERE for an online user That last row is why this counts as a regression rather than a rough edge. offline_only decides whether the playlist is a local_playlists row (local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity, and the shortcut derived it from connectivity alone — so someone with a Qobuz session could not create a local playlist without pulling the network cable. Ported against CreatePlaylistModal.slint plus on_create_playlist (main.rs:21107) and on_submit (:21496). The submit branches where the reference branches, and a creation forced local by being offline KEEPS the flag, as the reference's own note says, so Edit can unmark it later to enable "Upload to Qobuz". Two calls made here: - Create rides the QbzPlaylistEdit singleton rather than getting its own, where the reference splits them. Same domain — a playlist's own metadata — with two documents that never interact, and a second QObject is a second boot() to forget; that bridge's header already documents a missing boot() swallowing publishes silently and forever. - The folder picker hides on the offline-only arm. folders_qt::move_playlist takes a u64 and a local:<uuid> has none, so the reference shows the control and discards the value. The shortcut is deleted rather than left beside the modal: a second creation path that skips it is the fork the port rule forbids. All thirteen strings were already in the catalogues. cc |
||
|
|
e1876c15f0 |
feat(qt): the artist page's radio disc goes live, with a start-up indicator
The disc was btnEnabled: false with a comment saying neither engine had a seam on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio (/radio/artist) — and that is what this restores. The smart arm already shipped behind Discover's spotlight RADIO card and simply was not wired here; the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album radio uses, because its track objects are just as bare). Both labels already exist in all eight catalogues, so no new msgid. AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write; until the first track resolves nothing on screen moves, so the click read as dead. QbzCircleAction gains the `loading` arm the reference already has (CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins only the station that was clicked instead of all six. The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no partial redraws, so a continuous animator presents the whole window at display rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per ~30 Hz tick, and the Connections is disabled unless the button is spinning and on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a RotationAnimation and is deliberately not reused. On the cards, `loading` also pins the hover disc up: it only exists on hover, so it vanished the moment the pointer left after a click, which is what made the click look swallowed. A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty result all clear it. A disc spinning forever is worse than no indicator. cc |
||
|
|
e816436fd0 |
feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.
Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:
1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
table. Slint can derive it because its entries carry the tab
(Favorites { tab }); here an entry is a bare route id, so a derived scope
would read "library" for all five Library tabs and pour the Albums grid's
offset into whatever tab mounted. Letting the reporter name its own scope
buys tab granularity for free, and the mismatch case degrades the right way:
an unmatched scope restores NOTHING instead of restoring the wrong thing.
2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
contentY to 0 whenever its model is replaced, and several pages assign their
model from a binding whose inputs arrive over the network after the mount —
the album page rebuilds listCells from three deferred rails, so it hands its
ListView a new array up to four times in its first seconds and snaps back to
the top over a restore that already ran correctly. The offset is held and
re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
drag or a flick raises `moving`, and from then the position is theirs).
Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.
cc
|
||
|
|
47cab502f4 |
feat(qt): remember the immersive shader scene, not just the panel triple
"Remember the last immersive view" covered view_mode / mode / split_panel and NOT the scene, so Plasma, the Spectral Ribbon, the Line Bed and Tunnel Flow were exactly the views it could not remember. Worse, apply_open force-reset the scene to Off on EVERY open edge, which is Slint's behaviour (main.rs:10300-10301) and is only correct because Slint never stored one. The scene now persists through a custom WRITE on the property, so every writer funnels through it: the menu rows, the `g` ring and ShaderSceneLayer's shader-error latch. That last one persisting Off is intended — a scene whose effect failed to compile on this box must not be what the next open restores. The open edge restores it behind three gates: the default-view pref is "remember", the box is on the shader tier (off-tier the scenes render BLACK, so restoring one would open immersive onto a black rectangle), and the stored number is a scene this build ships. Any of them failing falls back to the Off reset, which stays the fallback rather than disappearing. The pref key is new to the shared ui_prefs.json and unknown to the Slint typed struct. Worst case there is that a Slint write drops it and this falls back to Off; the document is not poisoned. cc |
||
|
|
221572b36e |
fix(qt): hydrate the view a session restore lands on
"Startup page = where you left off" opened Library on an EMPTY page: the feed was still [] until the user clicked Library in the sidebar. Same hole for Mixtapes and Collections. navigate_to is not the only door. A restore mounts its view by calling nav_qt::record directly from on_session_entered — it must not clear the landing tab or the detail latch of a session that has not started — and record is history bookkeeping, nothing more. The per-view lazy load lived inside navigate_to, so the restore path never ran it. Split that load into hydrate_view and call it from both doors. Home never showed the bug because load_home_once is its own unconditional phase of shell entry; `local` is absent from the arm on purpose and is not a gap, since LocalLibraryView loads its tab from Component.onCompleted. cc |
||
|
|
d1d96e6918 |
fix(qt): the sidebar playlists menu stops going translucent under ambient
Sidebar.slint:920-922 gives that panel the sidebar's OWN ambient treatment (surface-card at 50%), and the port copied it. That made it the one menu surface in the app that reads through the artwork while the dynamic background is on. Every other menu and flyout paints flat and opaque: QbzContextMenu and therefore CardMenu, SidebarRowMenu, ViewModeMenu and the audio flyout, plus NavFlyout, SidebarFolderFlyout, QconnectFlyout, GenreFilterPopup and LocalFilterPopup. Swept all of them; this was the only outlier. A menu is text being read against art that moves underneath it, not chrome, so the outlier loses. cc |
||
|
|
da8fc598f8 |
fix(qt): the immersive trigger only moves right on macOS
|
||
|
|
3366cc3997 |
fix(qt): Metal sides with OpenGL on the RHI mirror, not with Vulkan
The rule was derived from two data points and generalised into a negation:
owner's Vulkan + mirror=false -> upside-down (Vulkan needs the mirror)
Xvfb Mesa GL + mirror=true -> upside-down (GL does not)
"mirror on everything EXCEPT OpenGL" fits both, and it was wrong about the one
API nobody had run. On the Mac mini M2 the Line Bed comes out upside-down, so
Metal wants mirror=false like OpenGL rather than =true like Vulkan — the
generalisation reached a third platform on the strength of a shape it had never
been tested against.
Kept as a NEGATION with Metal excluded rather than rewritten as "Vulkan ||
D3D". Naming the APIs that DO mirror would silently stop mirroring on any enum
this file failed to list: an unknown member is `undefined` in QML and compares
false, so a typo or a future backend would degrade to "no mirror" with no error.
This way only Metal's behaviour changes and every other backend keeps exactly
what it was verified with.
All four RHI scenes carry the line, because the convention belongs to the
graphics API and not to the scene — a per-scene split would be the surprising
result, not the expected one. Only LineBed made it observable: it is the one
directional scene, so a flip reads instantly, where Plasma and TunnelFlow are
near-radial and hide it. RIBBON IS THE ONE TO CHECK NEXT — it is a spectrogram
with a time axis, so if the convention really is per-API it should have been
flipped too, and if it now looks flipped the rule is per-scene after all.
The three sibling headers said "mirror on Vulkan/Metal/D3D" and were corrected
in place; they had become comments that contradict their own code.
Built and smoked on Linux (gate green). The observation this fixes is the
owner's, on the Mac.
cc
|
||
|
|
9c47d5b2e7 |
fix(qt): the immersive view trigger moves beside the window controls
It sat at the band's LEFT edge, which on macOS is the traffic lights' corner — x ~ 7-85, the pill's exact spot. The band was therefore being inset sideways to make room for one 44px control, and that inset is what read as wrong: the shell HeaderBar's rule is right for the shell, where the lights head a full toolbar row, and wrong here, where the row is three floating glass pills over artwork. Moving the pill instead of the row is the smaller change and the better one. The left half of the band goes empty and the lights get that corner to themselves; the search field is CENTRED on the band rather than laid out after the trigger, so nothing else shifts. Every other platform is unaffected — the trigger simply changed sides. Its right margin RESERVES the capsule's expanded width (112) plus a 12px gap rather than anchoring to the capsule's live left edge. The capsule grows leftward on hover, so anchoring to it would slide this pill sideways every time the pointer crossed the other one — motion with no meaning. Reserving the maximum keeps both still. The radius becomes height/2, the same silhouette as the capsule it now sits next to. An earlier attempt dropped the WHOLE band below the lights; it is reverted here. It moved the search field and the capsule down to solve a problem only the trigger had. The capsule stays. Worth recording why, since it looks redundant on macOS and is only half so: its fullscreen toggle writes `Window.visibility`, which the green light already does — but its X calls `QbzImmersive.open = false`, which EXITS IMMERSIVE, and no traffic light can do that. It is one of three exits (Escape and the player bar are the others) and the only visible one in the chrome. Built and smoked on Linux, built on the Mac mini M2. The macOS geometry needs eyes on a Mac. cc |
||
|
|
d1fd99f947 |
refactor(i18n): qbz-i18n owns its catalogues; macOS immersive band drops below the lights
QBZ-I18N WAS NOT FRONTEND-AGNOSTIC, whatever its own header said. No slint or tauri entry in its Cargo.toml — and eight `include_str!` reaching into `crates/qbz-ui/translations/`. Deleting the Slint crate, which is planned before the Qt release, would have failed to compile `qbz-i18n` and with it `qbz-qt`, i.e. the entire app, over a data directory nobody read as a dependency. The extraction that created this crate took the code and left the data behind. The eight .po files move to `crates/qbz-i18n/translations/` (git tracks them as renames, so history follows) and the includes become `../translations/`. `qbz-ui/build.rs` borrows them back through `../qbz-i18n/translations` for as long as that tree exists — the direction of the dependency is what matters, and it now points the way the deletion needs. MACOS: THE IMMERSIVE BAND GOES BELOW THE TRAFFIC LIGHTS, not beside them. It was inset to their right with the shell HeaderBar's rule, which is right for the shell — the lights sit at the head of a full toolbar row there — and wrong here, where the row is three floating glass pills over artwork and a 44px pill parked against the lights reads as debris. Vertical clearance comes from `SafeArea.margins.top`, the same question the left inset asks in the other axis: with ExpandedClientAreaHint the client area covers the titlebar region and SafeArea reports how much of it is unsafe, with a 28px floor for a 0 reading. The band keeps `leftMargin: 24` on every platform, which puts the trigger's leading edge in the same 20-24 band the lights start in, and the 16px gap under them is the band's own margin reused rather than a new number. The view trigger matches the cluster's width on macOS (SafeArea left margin, 78 floor): stacked, a 44px pill under a ~78px cluster reads as misaligned even with the left edges lined up. Its radius becomes height/2 rather than a constant 18, or the widened pill would have been a rounded rectangle beside three circles. Every other platform keeps the natural 44px, and the glyph was already centred so it needed nothing. The menu opens downward from a band that now has more room under it, so the flyout has more clearance than before rather than less. Built and smoked on Linux (gate green) and built on the Mac mini M2 (3m32s, no errors). The macOS geometry itself needs eyes on a Mac. cc |
||
|
|
1352e499f9 |
fix(qt): the hand-written RHI items now compile on macOS
`build_rhi_items()` compiles the four QQuickRhiItems in `cxx/` with its own
cc::Build, and its macOS arm had never run: those files landed in
|
||
|
|
931cb06d38 |
feat(qt): ease the visualizers on the pulse, and make Plasma react
TWO FINDINGS, both of which falsified something that was being assumed.
FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100
frames/s — qt-batches confirmed it, and the script now prints the pulse it ran
with so two logs cannot be confused — and the bars looked exactly as they had at
30. They would: every published frame was applied as a STEP, so the extra
presents repainted the same discrete values three times over. What the Slint
build does differently is not rate, it is `animate bar-h { 90ms }` — it moves
BETWEEN values.
So the interpolation is back, ON THE PULSE, and it costs nothing. The old
interpolator was expensive because of its private 16 ms Timer, not because it
interpolated: it presented at its own rate, unsynchronised with everything else.
Easing on the pulse presents exactly as often as stepping on the pulse did — one
repaint per period either way, only the value differs. Self-parking survives: a
converged ease writes nothing and schedules no repaint. The price is lag, and it
is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching
the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing
already in the chain.
The from/to/progress triple is gone with it, and its consumers moved to `cur`.
Note for the next reader: `SpectrumBand` drove all three of its modes from
`Connections` on those properties, and a Connections handler naming a property
that no longer exists is NOT a compile error — QML warns at runtime and simply
never fires. That is how the whole band went dead at once; grep for
`on<Name>Changed` when removing a property, not only for direct reads.
PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones
travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were
published, parsed and stored, and never handed to the item — the shader declared
them and received zeros — so everything ran off the energy bands, which decay
x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms).
Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so
above ~3 hits/s it never falls far enough to come back as a spike and settles
into a shallow ripple around 0.7. A splat multiplied by it becomes a steady
glow, and the busier the music the flatter the picture — the most frenetic input
produced the least motion. `beatAc` subtracts a slow density floor, so a hit
reads as a hit at any tempo.
The scene itself was rebuilt around the reference the owner filmed (Butterchurn
on a percussion-heavy track). Its recipe is three parts: one thin annulus
injected per frame that the feedback warp marches inward, so the concentric
rings ARE the last N frames of audio laid out in space; retention high enough
for that to stack (this held 0.85, where a trail died in ~15 frames and nothing
ever accumulated); and angular structure from reading the spectrum BY ANGLE.
But a CLOSED ring was wrong, and the owner named why: a perfect inner
circumference reads as a drawn shape, hard-edges the field, and at this
retention successive rings pack into one solid disc — which is what killed the
ambient feel. The annulus is gated by angle now, so a hit paints arcs the
conveyor carries inward, the circle never closes, and the gaps stay open for the
background. Noise bends, frays and thins those arcs so they are made of plasma
rather than of compass.
The worms are back at full strength with tighter cores and a contrast curve — a
raw gaussian is all shoulder and reads as haze — and their orbits now reach past
the arc band instead of being trapped inside it. Colour comes from ONE
album-derived triad, so everything used to be a shade of the same hue; a
Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart
whatever album is playing. That wash is what stops the background being void for
the foreground to be a cut-out against. A soft limiter replaces the hard clamp:
at 0.945 retention a clamp pins the middle to white and the structure vanishes.
Release build clean, offscreen smoke green, 421/421.
cc
|
||
|
|
730ae6998e |
feat(qt): windowed View-all artwork, a real per-rail default, Play later, MyQBZ tabs
VIEW-ALL PAGES GOT THE LIBRARY > ALL TREATMENT, in both halves. PlaylistBrowse windowed nothing: every card mounted, and a View-all of playlists runs to hundreds, each a PlaylistCard carrying a four-tile collage. AlbumCollection had solved that for the album pages and this one never got it. The band is sampled on a timer rather than bound to contentY — a direct binding re-evaluates every delegate on every scroll frame — and out-of-band cards keep their footprint as bare Items, so the scroll geometry and the infinite-scroll trigger never move. Covers arrived in ONE batch: Rust downloaded every missing cover of the page and then republished the whole document to attach the paths, so a page had no artwork until it had all of it, and the republish rebuilt every delegate on arrival. Both browse pages now ask for what is near the viewport and take the answers one key at a time through the existing sidebarArtworkWindow -> libraryArtworkReady seam. `attach_card_art` stays — it is a pure disk-cache lookup, so anything already downloaded still renders on the first frame with no request. The two now-dead republish helpers are gone with it. The AlbumCollection change is ADDITIVE for its nine hosts: those that pass no `flick` never report, the map stays empty and `artOf` falls through to the `artPath` they already publish. ITEMS PER CAROUSEL now has a real default instead of a lie. The first entry read "All", which was wrong twice over: an "all" in Qobuz terms is what the View-all page holds, and /discover/index hands each rail a different count anyway — measured on a live response, new_releases 26, ideal_discography 26, playlists 20, most_streamed 15, and qobuzissims / album_of_the_week / press_awards 10. No single label could be honest about all of them, so the default became a real uniform cap of ten and the entry says so. That is a behaviour change and not only a label one: the rails that used to arrive at 26 now arrive at 10, and the select is how a rail gets more. Ten therefore stops being a separate option because it IS the first one, and the number comes from Rust so the label and the cap cannot drift. The select also went 82 -> 124 wide: "Default (10)" elided to "Defaul…" in both the closed control and the popup. PLAY LATER on the album page's track rows. It was off because `enqueue_album_track` had only two arms — "next" and "everything else appends" — so "later" and "queue" both plain-appended and offering both would have been two menu entries doing one thing. The third arm lands on the manual block's tail via `add_track_later`, which is the whole distinction, and the QConnect route stops collapsing "later" into "queue" (route_track_to_peer has had a real later arm all along). Only then does the entry turn on. MIXTAPES <-> COLLECTIONS as a tab bar, the Discover/Library pattern where the tabs stand in for the page heading. They are separate ROUTES sharing one component, so a tab click navigates rather than flipping a local property: the sidebar highlight, the back stack and the `kind` Binding then agree on where the user is, and the page transition is the one every route change gets. Eight locales. Release build clean, offscreen smoke green, 421/421 and 216/216. cc |
||
|
|
e9f09a59a4 |
feat(qt): opt-in — a click on a section row opens its first tab
Settings > Appearance, OFF by default. With it on, clicking Discover lands on Home and clicking Library lands on All, instead of only opening the section's flyout and waiting for a pick. The rule lives in NavFlyout because the catalog does: "the first tab" is `entries[0]`, and routing it back through `activate` means the My QBZ section — whose entries are plain views carrying no tab — needs no special case. Both hosts call it from their row's click next to the open they already do, so the sidebar and the title bar cannot drift. The pref rides `settingsJson` like the two Purchases toggles beside it, so nothing is wired beyond the write and the republish every settings toggle already does. The read is a guarded parse: a bare JSON.parse in that binding throws on the pre-publish frame and would take the whole nav catalog — and therefore both hosts — down with it. Eight locales. Release build clean, offscreen smoke green. cc |
||
|
|
81e172457f |
fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a manual refresh — while the plays were sitting in the database the whole time. Read off the live file rather than reasoned about: `playlist_play_events` held 3, 9 and 10 rows for three playlists, so the context edge was working perfectly; `playlist_meta` was empty, and its `.schema` said why — no `covers`, no `own_image`. `covers` and `own_image` were added by inlining them into the CREATE. `CREATE TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who had run the previous build kept the six-column table, every upsert failed on "no such column: covers", and the rail JOINs meta against events — so it rendered its empty state with the evidence one table away. The failure was invisible by construction: swallowed into a warn, and the UI's empty state is also its correct state for a user who has played nothing. A brand-new table is only new ONCE. The moment a build runs anywhere its schema is legacy and every later column is a migration, so the pair moves to the same idempotent ADD COLUMN the album history already uses, and the upsert's failure becomes an error rather than a warn. Verified against a copy of the real database: the columns land and all 22 events survive. A regression test builds the exact pre-covers schema, records a play into it, migrates, and asserts the upsert works and the old event still counts — it reproduces the bug without the fix, and it also asserts a second `init_schema` is a no-op, since every launch calls it. The meta is written by `playlist_qt::load`, so the three playlists already in the events table surface as soon as each is opened once. BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader: Binding loop detected for property source". `_stampRoute` runs inside the `source` binding, and it read the last path from a `property string` and then wrote it — a binding depending on a property it mutates. QML detects the cycle, warns, and stops re-evaluating, which is a broken router and not merely a noisy log. The holder becomes an object whose MEMBER is mutated: no change signal, so the dependency is satisfied once and never fires again. (The second warning on the same file, the `when` loop on the nav-flyout `activeTab` Binding, is untouched by any of this work and predates it. Left alone deliberately — it is a warning on a working feature, and rewriting that Binding is its own change.) ZEBRA on the playlist detail's track rows, like the local album page. TrackRow already owns the stripe, so it is one property. Release build clean, offscreen smoke green, 421/421 and 216/216. cc |
||
|
|
907d366d71 |
fix(qt): Library artwork loaded one row; add Refresh content to the configurator
LIBRARY ALL / ALBUMS LOADED EXACTLY ONE ROW OF COVERS and then nothing until the user nudged the scrollbar. The grid and the list reported their artwork window on contentY, model, width, visible and completion — but NOT on height, and their height is gated on `visible` (`height: visible ? parent.height - 16 : 0`, the 2026-08-17 change that stops a hidden body from refilling). QML gives no ordering between "the `visible` change handler runs" and "the `height` binding that depends on `visible` re-evaluates", so when the handler wins the reporter reads height 0 and computes `lastRow = ceil(0 / cellHeight) + 1 = 1` — a window of exactly `cols` items. One row. Nothing fires again until a scroll, which is why moving the scrollbar "fixed" it. Playlists were unaffected and that asymmetry is the tell: PlaylistCollage dispatches its own urls per delegate, independent of the window, while album cells live off `artMap`, which only the window report fills. The control case is in the same tree — LibraryAlbumsList, the playlists list and the Artists rail all report on height and none of them has the defect. The two bodies that lack the handler are exactly the two whose height is gated on visibility; `width` had one because width always changed, `height` never did before the gate. REFRESH CONTENT in the Discover configurator. The index is fetched once per shell entry (`HOME_LOADED` latches and `reload_home` is never called again), so a session shows the page it opened with — no new catalogue rows, and none of the plays made since, because the recently-played rails are built from the local history in that same pass. Everything else in that modal re-renders from the cached candidates, which is precisely what cannot produce fresh ones. The invokable already existed for the error-state retry; this is the door. The Reset button's markup became a shared FooterButton so the pair cannot drift. cc |
||
|
|
f0d753a751 |
fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of
|
||
|
|
26ceeab828 |
feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.
MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.
Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.
PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.
The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.
SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.
CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.
APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.
RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.
ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.
Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.
Also fixes two recommendations_qt tests that had been red since
|
||
|
|
047db8ea9f |
perf(qt): recycle the album page's track list, on a uniform-cell ListView
DID NOT DELIVER ITS GOAL. Read this before extending it: the wheel scroll on
a 189-track album is essentially unchanged. The page is correct and the
structure is better, but the theory that motivated the work is not supported.
What the work assumed: the ceiling was the `Repeater`, which has no windowing
and so built all 189 track blocks — each a Column with a disc-header Loader, a
work-header Loader (both gated on DATA, never on the viewport, so every act and
scene header in a Wagner box was alive) and the band-gated row. Roughly a
thousand structural items before counting anything visible.
What it is now: one ListView over a UNIFORM 10px cell tape. Each piece of
content owns a run of cells, its visual item lives in the first cell of the run
and paints over the inert ones — 50px row = 5 cells, 40px header = 4, rail =
29. Uniform cells are the point: measured in a standalone probe, a ListView
with variable delegates ESTIMATES contentHeight and ignores an explicit
override (5703 then 5396 against an exact 5400), which makes the scrollbar
thumb resize and skip as sizes are discovered. With uniform cells,
count * 10 is exact from the first frame, so the scrollbar the owner had just
called the best gesture on the page keeps its behaviour.
Falling out of the design, replacing machinery that was reverted a commit ago:
`cacheBuffer` incubates upcoming delegates asynchronously in idle time, so a
heavy rail is built before it is visible without a hand-rolled band, a settle
Timer, or an unload grace — and without the "reach the bottom, then wait" that
made the owner reject that approach.
Two correctness items that came with it:
* `header` is a ListView property. The page's own `header` had to become
`albumHeader` or every `header.x` binding inside the view would silently
resolve against the view's header instead. One handler was missed in the
rename and QML refused to create the component at all — the whole page
rendered black, and no static gate caught it because the failure happens at
instantiation.
* `recycleActive` on the shared TrackRow, default true, gates the row's
Connections. Without it a pooled row keeps listening and consumes a cache
or favourite settle signal meant for a different track. Every other host
is unaffected by the default.
WHAT THIS RESULT MEANS, for whoever picks it up: delegate count and
construction cost are NOT what makes this page feel heavy. Removing ~950
structural items changed nothing the owner could feel, which points at
per-frame cost instead — consistent with his own observation that the page is
sluggish ALWAYS, not only while it loads, and with the documented finding that
this shell's bill is full-window presents rather than scene work. Do not
re-attempt a structural rewrite here on intuition. Take the QSG_RENDER_TIMING
split (polish/sync vs render) on this exact album first; the earlier reference
scene measured polish=0ms, render=2ms, swap=12-24ms.
Design and probes came from an independent review; the rebase onto the current
tree, the black-screen fix and the API verification are mine.
cc
|
||
|
|
3fdccae22e |
Revert "perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll"
This reverts commit
|
||
|
|
313a7e7e4e |
fix(qt): the local album page gets its zebra, across the whole row
The shared TrackRow has had a `zebra` flag all along, off by default, and the Qobuz album page turns it on. The local one never did — and turning it on at the call site would not have been enough either, because LocalTrackRow wraps the shared row without forwarding the flag. Forwarding it is still not the fix. TrackRow tints its own background rectangle, which is `parent.width` wide, and this wrapper hands the shared row `root.width - 26 - 46`: a 26px gutter for the source glyph and 46px for the context menu the wrapper draws itself. A stripe painted from in there stops 72px short and leaves both controls outside it — which is what the smoke caught. The stripe is therefore painted by the wrapper, full width, declared before the shared row so it lands underneath and takes no pointer events. Same colour literal and same parity rule as rows/TrackRow.qml, so a local list and a Qobuz list stripe identically. cc |
||
|
|
5b99a6698e |
perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll
The three SectionRails at the foot of the album page were gated on `visible:`
alone, and in QML `visible: false` stops an item PAINTING, never building. With
data present all three were fully constructed the moment the page mounted —
three horizontal ListViews of AlbumCards, every cover a RoundedImage with two
render targets — whether or not the user ever scrolled down to them. On a long
album nobody reaches the bottom of, that is pure cost, and it sits in the SAME
Flickable as the track list, so those layers are in the scene for every frame
the list scrolls.
Each rail is now a fixed-size `RailSlot` with the real rail behind a
`Loader { active: inBand }` — the shape `rowSlot` in this file already uses.
Three things this had to get right:
* The slot reserves 286px whether or not it is built (28 header + 12 spacing
+ 246 card band, the figure RailSkeleton already stands in with). A slot
sized to its Loader would change the page length as rails build and slide
the content under the user mid-scroll.
* The band reads the slot's OWN `y`, which the Column assigns from preceding
siblings only. The variant measured against a parent-derived height is a
dependency cycle; it cost a day on 2026-08-18, because QML breaks the loop
silently and every slot then reads as in-band.
* An inline `component` does not share the enclosing document's id scope, so
`page` / `pageFlick` / `root` are not resolvable from inside RailSlot. The
geometry and the cover map are handed in as properties instead. Left as it
was, the failure would have been silent: a band evaluated against zeroes.
Two follow-ups from the owner smoke, both about WHEN the Loader flips:
* UNLOAD IS DELAYED 600ms. This page grows in stages (primary document, then
deferred rows), and while the list is missing the page is short enough to
put these rails inside the band. They began building, the rows landed, the
page grew by thousands of pixels and the Loader tore them down again —
and since a ListView creates delegates through incubation, tearing down
mid-flight is exactly the "Object or context destroyed during incubation"
warning that appeared three times, once per rail, on every album open.
* BUILD WAITS FOR REST. A Loader creates its content synchronously, in one
frame, on the GUI thread. Building the instant a rail crossed into the band
put that spike inside a wheel scroll — measured by the owner as the wheel
getting worse exactly as the scrollbar got much better, which fits: a drag
ends before the spike lands, a wheel scroll does not. Creation now waits
for `flickY` to stop ticking. That signal works for the wheel and the
scrollbar alike, unlike `Flickable.moving`, which a scrollbar writing
contentY directly does not necessarily raise. Already-live rails stay live:
this gates the build, never the keep.
cc
|
||
|
|
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
|
||
|
|
99722aedaf |
feat: an unreachable track can no longer stop playback
Two owner requirements, one shape: something that cannot play must be skipped, and cleaned when there is nowhere left to skip to. The causes are outside our control — Qobuz withdraws a track overnight, or a local/Plex file sits on a share this network cannot see today. THE FUNNEL, which is where the real gap was. `play_current_if_local` returned a bool whose two `false`s meant opposite things: "not my row, run the Qobuz path" and "my row, and it cannot play". The caller could only act on the first, so an unreachable local file fell through to the Qobuz tier-walk, failed there with an unrelated error, and `auto_skip_unavailable` then declined it as non-terminal. One dead file stopped playback silently. It is now a three-state `LocalPlay`, and a local failure takes the SAME bounded walk a pulled Qobuz track takes — to the user they are the same event. CLEANING. Both dead ends of that walk (budget spent, queue edge) now call `purge_unavailable_upcoming`, which drops only rows `queue_track_unavailable` already refuses to play, walking backwards so a removal cannot shift an index still to be visited. A track on an unreachable share can never land there: it is Unreachable, not dead. PROBES THAT CANNOT HANG (`qbz-library/src/reachability.rs`, 4 tests). The machinery for network shares already existed — mount_info, `library_folders.is_network`, a populated `local_tracks.is_network_mount`, even an unreachable-folder guard in scan.rs. Every one of them probed with a bare blocking syscall, and a mounted-but-unreachable share does not make `exists()`/`read_dir()` fail, it makes them BLOCK for the mount's timeout. The guards could not fire; scan.rs hung on the very condition it existed to detect. Three states, not two, and that distinction is the whole safety property: `Missing` is the filesystem saying the file is gone and may be cleaned; `Unreachable` is it saying nothing, and may only ever be skipped. Collapsing them — which a bare `exists()` does — would delete a user's NAS library because they changed networks. The deadline goes on the PROBE only, never the transfer: a hi-res FLAC over a working-but-slow share legitimately takes longer than any probe budget, and timing the read out would turn "your network is slow" into "this track is gone". A blocked syscall cannot be cancelled, so a timed-out probe leaks its thread; the mount is therefore remembered with a cool-down, one probe per dead mount rather than one per track, which on a 2000-row queue is the difference between a defence and a thread bomb. PERSISTENCE. `busy_timeout=1000` on all 18 store pragmas. Deliberately NOT the 2-3s discussed: these writes run on the Qt thread, so a 3s wait would install exactly the stall this whole sweep has been removing, while today's 0 means contention loses a setting silently and instantly. One second resolves transient contention without a visible stall. `home_bridge::ui` announces both of its silent failure modes. Three builds went into proving it was NOT dropping the Home document during the launch freeze; one warn line would have answered it in one. Verified against the owner's real session state (1935-row queue): the app launches to a painted Discover with no QML errors and no dropped publishes. 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 |
||
|
|
ccd5272cc3 |
feat(qt): let a nav-bench hop name its landing tab
`library:all`, `local:tracks`. A tab switch inside a mounted view changes no route, so the route stopwatch never fires for it — hopping away and back with a landing tab is what puts a given tab's cost on the clock, which is how the Library grid/list measurement was taken. It also drives the nav-flyout entry path (navigate_to_tab) rather than the plain sidebar one, so the gate now exercises both. 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
|
||
|
|
66ba12bd53 |
fix(qt): stop a long queue freezing the app at launch
A session with 1935 persisted queue rows came up frozen for ~28s at
120% CPU, every launch, deterministically. It looked like corrupted
local state — moving ~/.local/share/qbz aside "fixed" it — but nothing
was corrupt: that is simply a queue the UI could not carry.
The chain. AppShell mounts ImmersiveView unconditionally (its own
comment: while closed it is "an invisible, non-interactive Item"), and
`visible: false` stops an item painting, never building. CoverflowPanel
reads the flat WHOLE-queue document — unlike the queue panel's, which
paginates at 40 — and runs two Repeaters over it, the forward list and
a reversed copy. So every launch built ~4000 CoverCards, each an image
with a masked layer, for a panel nobody had opened.
Two gates, because either alone leaves it broken:
- CoverflowPanel and both QueueTabsPanel mounts move behind a Loader
keyed on the condition they already carried in `visible:`. That is
the launch freeze.
- The coverflow delegates become zero-size, non-clipping slots whose
CoverCard exists only within |offset| < 6.5 (the fan paints 3.5;
the margin covers the 320ms ease). That is opening it, which the
Loader alone would still freeze.
The reference does create one element per row and hides |offset| > 3
(CoverflowPanel.slint), and that is fine there: a Slint element is
cheap where a QML delegate is not. Copying its shape literally is what
cost the 28 seconds.
Verified against the real session state, queue restored, not a
synthetic one: 120% CPU -> 2.9%, and a screenshot of Discover fully
painted where it used to sit on the skeleton.
cc
|
||
|
|
a6b373cb5a |
build(qt): fail the build on a tab body that is built while hidden
The class the previous commit fixed leaves no trace a reader would notice: the view renders correctly, cargo is happy, both existing audits pass, and the only symptom is that entering the section takes seconds. So it needs a static gate or it comes back. qml_eager_tab_audit.py reports a per-tab body gated only on `visible:`, and reports it only when it is genuinely heavy — it must instantiate a Repeater/ListView/GridView AND that view's model must be data rather than an inline literal. Without the second condition it flagged the sort popups in LibraryToolbar, which are Repeaters over four fixed options, and three false positives would have buried the one real finding. 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
|
||
|
|
8015439304 |
feat(qt): drive navigation without a GUI, and time what a route costs
The cost of a route change is wall-clock on the UI thread, so no cargo test and no static audit can see it: measuring it needs the real app to actually navigate. QBZ_QT_NAV_BENCH=home,library,home walks a hop list after the first home publish, which paired with the qbz.nav.timing probes prints what each mount spent. It also mounts views the offscreen gate never reaches. That gate keys off QbzShell.currentView and nothing navigates during it, so it only ever instantiates the startup view — which is how two entirely unloadable modals and a live ReferenceError once survived fifteen parity rounds. 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 |
||
|
|
b2c74c4140 |
feat(qt): find an available version for a track Qobuz pulled
Reachable from a dead row's context menu, in a playlist the user owns. The reference had this and shipped it with four defects; none are ported. It was unranked -- Qobuz's raw relevance order shown to the human. The weighted matcher that already lives in qbz-playlist-import does the ranking now, and its ISRC short-circuit matters most: licensing churn usually republishes the SAME recording under a new id with the SAME ISRC, and the pulled track keeps its isrc (confirmed in the 2026-08-17 capture of album 0886443985094). That case needs no human judgement, so the ISRC lookup runs first and pins its hit to the top. It removed before it added, so a failed add lost the track outright. The order here is add, reposition, remove. It did not preserve position -- the computed index went to a console.log and the replacement landed at the end. It lands where the dead row was, through the new updateTracksPosition verb. Every failure in that step is a warning and a different success toast: the repair still stands at the end of the playlist, exactly where the reference always put it. Position is a nicety, never a reason to abort a repair. Two guards. If the ISRC lookup answers with the dead track itself -- it can, since a pulled track keeps its metadata and search still indexes it -- then "replace" would be a no_duplicate no-op followed by a remove, and the user would lose the row while being told it was repaired. And when the remove fails the playlist permanently holds both rows: that is not rolled back, because a rollback is a second write that can fail the same way and would delete the only good copy. The list is refreshed so the user SEES both, and the toast says the old one has to go by hand rather than claiming a reload will fix it. 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 |
||
|
|
8e52157299 |
feat(qobuz): add the updateTracksPosition verb
Reordering tracks inside a playlist had no client verb: addTracks takes only
[playlist_id, track_ids, no_duplicate] and appends, and updatePlaylistsPosition
reorders the user's PLAYLISTS, not the tracks within one.
The endpoint exists and is documented from a desktop web-player capture --
{playlist_id, playlist_track_ids, insert_before} -- not from the decompiled
Android route tables, which are not a valid source for this. Its exact argument
encoding is the one part no capture in the repo pins down, so every caller must
treat a failure as recoverable rather than fatal.
Written for the replacement flow, which needs the repaired track to land where
the dead one was instead of at the end of the playlist.
cc
|
||
|
|
371be04be8 |
feat(models): Track.streamable becomes Option<bool>, and absence stops meaning dead
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE, so a catalog Track from any endpoint that omits `streamable` came back marked unavailable. The models' own tests document this as a hazard rather than a bug (the "streamable split": PurchaseTrack defaults true, catalog Track defaults false, and the comment warns that a wrong default "inverts that whole screen's behaviour"). The inferred OpenAPI disagrees with the code and documents `default: true`. That was survivable only because nothing read the field. It stops being survivable the moment a frontend greys rows out and keeps them out of the queue: one terse endpoint would blank an entire view, silently. `None` now means the endpoint made no claim, and only `Some(false)` marks a track pulled. `is_streamable()` is the ONE place absence is interpreted, and it resolves to available: refusing to play a track because a response was terse is strictly worse than playing one that turns out to be dead, which the reactive skip path already handles. Album.streamable was already Option<bool> with a doc comment naming the same causes, so this makes Track consistent rather than novel. Every consumer in the workspace moves to the helper: both matchers, the ISRC validator, the daemon driver, the purchases service (which maps catalog Track into PurchaseTrack and therefore feeds the very screen the split protects), the radio pool, the mixtape resolver and the QConnect renderer. The replaced tests pin both directions of the new contract. Also here, because it lives in the same file as the matcher's streamable skip: `rank_candidates`, an additive public entry point over the existing weighted matcher (ISRC short-circuit, title/artist/album weights, duration bonus, stop-word normalisation, quality tie-break). `match_tracks` is untouched. cc |
||
|
|
3f5f27bba5 |
fix(qt): local playlist covers, and the playlist ref guard gets wired
Two dead-code warnings that were hiding real defects. A local playlist's cover was written to one store and read from another. The header menu persists through `cover_artwork_qt` to custom_playlist_covers.json -- deliberately separate, since a `playlists` key inside the shared custom_artwork.json is dropped by the other build's next write -- while the detail document read `library.db`'s custom_artwork_path, which nothing in this port writes. So a cover the user had just picked appeared in the sidebar and never in the header, and has_custom_cover stayed false, which meant the menu only ever offered "Add cover": Remove was unreachable. The document now reads the same store the menu writes. The DB column survives as a read fallback so a cover stored there by an earlier build is not silently lost, and the clear path is kept and wired into remove_custom_playlist_cover -- without it, such a cover stayed on screen with the menu insisting it had been removed. PlaylistRef was a documented type guard nobody constructed. Nine call sites hand-rolled it as is_local_id() then a separate parse::<u64>() -- correct at each site, but it left "a ref classified by the wrong half" representable, and that id-space hazard has already produced real bugs here: a LocalLibrary row id is a small integer that parses as a perfectly valid Qobuz id, it just means a different track. Classification now happens in one place, and a new call site cannot forget the test. cc |
||
|
|
05c5799192 |
feat(qt): Detected device limit row, its refresh triggers and cache drop
The last unported row of Settings > Audio. Read-only by design: a Hz dropdown is the documented root-cause trap, because it promises rates the app cannot request when Qobuz sells four discrete tiers. The value is Rust-composed from the probe and the row hides on an empty summary; the fallback caveat renders only when the probe fell back to the common set. No new msgids -- both strings already exist, translated, in all 8 catalogs. Six refresh triggers, one more than the reference: startup, the toggle, a backend switch, an output-device change, reset-to-defaults, and the device refresh/release button. That last one is Qt-only and the easiest to miss -- it is the hotplug case, where the hardware behind a "System default" selection changes without the selection changing. Ordering is persist -> refresh -> publish, and the refresh goes BEFORE apply_audio rather than merely before the handler's closing publish: apply_audio publishes on its own way out, an earlier serialize that would ship a stale summary. The symptom of getting this wrong is a row that is only right after reopening Settings. The boot trigger publishes the snapshot itself after awaiting the refresh. The boot snapshot is spawned separately and can serialize first, which left the row absent for a user who already had the toggle on until some unrelated mutation happened to republish. A cap change drops the tier-keyed audio cache, symmetric with the streaming -quality change: the cache is quality-blind, so bytes fetched under the old tier would keep serving and the feature would be inert on anything already played. The first refresh of a process is exempt -- see the CapChange classification, which exists because clearing there wiped the disk cache on every launch. cc |
||
|
|
3aed6d3ea6 |
fix(qt): every local play goes through the funnel, which now owns the tier
play_resolved_offline_aware was meant to be the single point every local play passes through, but the quality was a PARAMETER of it, so seven callers re-resolved the same value and five bypassed it outright. The five bypassers are a live bug with nothing to do with #638. MyQBZ (x3), playlists and the gapless prefetch called the core directly with None, None for the offline handle and the row sink, and 0 for the resume position. So playing a collection or a playlist ignored the offline cache entirely: a downloaded track re-downloaded, and failed outright with no network, while never feeding the offline-cache progress row. Gapless had the same hole under a comment claiming this port opens no offline index, which the funnel above it already falsified. The funnel now resolves the request tier itself through local_playback_quality, which reconciles the streaming preference against the detected device cap. It costs two uncontended lock reads, so there is no argument for hoisting it back out. quality_state drops its own cap-free copy and calls the same function: a badge that reports a tier the play path did not request is worse than no badge. route_play_to_peer deliberately stays at the call sites. play_queue_track needs the peer route BEFORE its local-file branch, and the MyQBZ collection play does not early-return on a routed play because its recency bookkeeping runs either way; hoisting it would force a tri-state return and weld a routing decision onto a quality one. cast_qt::effective_cast_quality still reads the UNCAPPED preference. The local DAC is not in a cast's signal path, and capping it there is #638 in reverse: a 48 kHz DAC silently limiting a 192 kHz DLNA renderer. A source audit test pins the exact call-site set, since no runtime test can reach it. Known limit, documented at the funnel: the cap governs what is REQUESTED. An offline copy already on disk is served verbatim, because downloads are always taken at the top tier. cc |
||
|
|
f75e1a4f47 |
feat(app): add frontend-agnostic device_cap module (#638 fix 3)
The local output-device quality cap lived in the Slint binary crate, which is why the row never reached the Qt frontend: it is app-glue in a crate Qt cannot depend on. Added to qbz-app, which already depends on qbz-models, qbz-audio, tokio and log, so no new dependency edge. The Slint copy is left untouched and keeps its own now-stale version. Two behavior changes over the original, both deliberate: Tier boundaries are tightened. A tier is a CEILING the service may deliver at, so naming one the device cannot play defeats the cap: an 88.2 kHz DAC must not resolve to Hi-Res (deliverable at 96) and a 176.4 kHz DAC must not resolve to Hi-Res+ (deliverable at 192). refresh() now returns a CapChange so the caller can decide about the audio cache without a racy before/after read. The first refresh of a process is Seeded, not Changed: the cap cache starts empty, so at boot None -> Some looks like a user change, and the invalidation it triggers unlinks every cached audio file on disk. A naive comparison wiped the whole playback cache on every launch with the cap enabled. A generation counter, re-checked while holding the CAP write lock, refuses a superseded commit. Without it "enable (slow probe) then disable" could let the stale probe resurrect a cap the settings say is off; checking the counter before doing the work left exactly that window open. refresh() also takes the active backend, so a "System default" selection resolves through the backend that is playing instead of always PipeWire. cc |
||
|
|
bad0e3aed3 |
fix(qt): search album cards read the nested audio_info and dates too
Sweep after the award-grid fix, comparing every album-shaped surface against
the reference's canonical reader (`album_map::map_album`).
Search was the one live gap left, and it is not a guess: `search_albums`
returns the very same `SearchResultsPage<Album>` that `get_award_albums`
does, and that endpoint demonstrably answers with the nested fields only.
Its mapper read `release_date_original` and `maximum_bit_depth` flat, so the
same missing date and missing badge the owner saw on the award grid were
sitting in search results.
The three chains are shared helpers now (`album_release_date`,
`album_audio_parts`, `album_quality_tier`) rather than a third hand-rolled
copy — the next album surface gets them by calling, not by remembering.
Everything else in the sweep is already correct or matches the reference:
- nested-aware already: album page cards, artist, artist scene, library /
favorites, label, myqbz builder, playback
- flat in BOTH frontends, so shared debt and not a port gap: the album page
HEADER (album.rs:275 reads flat too), the album info modal, and
purchases
- every Track reader is fine by construction — `Track` has no `audio_info`
and no `dates` fields at all
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 |
||
|
|
c2218dcdb2 |
fix(qt): read the nested audio_info and dates on album cards
The award grid drew cards with no release date and no quality badge while
Discover's looked fine, and the reason is the same one twice:
`map_flat_album` read only the FLAT fields, and /award/getAlbums answers
with the nested ones. The reference's `album_map::map_album` has read both
shapes all along.
Three fallback chains, ported from it:
- date: nested `dates` (original > download > stream), else the flat
`release_date_original`
- bit depth and sample rate: `audio_info` first, else the flat pair
- tier: falls back to the hi-res FLAGS (`hires || hires_streamable`) when
no depth arrived at all. A payload can say "this is hi-res" without
saying how, and the old match answered "" — no badge — for exactly that
Also `> 16` rather than `>= 24`: a 20-bit master is hi-res.
cc
|