The write existed — `dismissArtist` persisted to the reco-dismissal store
and toasted — but nothing READ it: `reco_dismiss_qt::ids_snapshot()` had no
consumer outside its own module, so the artist came back on the next
rebuild. The card's comment promising it would "leave the rails on the next
publish" was false, because nothing filtered on publish either.
The dismissal ids now join the exclusion set fed to `compose_artist_rails`,
so the composition choke point can backfill the freed slot from the pool
instead of leaving a hole, and `apply_dismissal` drops the card from the
cached rails on the CLICK — no optimistic flip in QML.
Scope is deliberate and recorded on the card: RECOMMENDATIONS only. The
same card is mounted by Search, Label, Artist Scene, Home and the Library
grid; dismissing from one of those persists and takes effect in
Recommendations, but the card you clicked stays where it is. Hiding an
artist app-wide is the blacklist — a different store and a different row.
cc
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
PARITY-DEBT #6, both halves. `all_local_scope` was read-only here and the
feed's "all" branch did not exist, so Settings > Local Library > LIBRARY ›
ALL was absent and the only local content in the All feed was hearted
items. `all_local_feed_blocking` ports the whole rollup — albums with the
Plex union, artists, tracks paged to the reference's 200k ceiling — over
the same queries the Local Library tabs run, so the two views cannot
disagree about what is in the library.
Two deltas from the reference, both because the Qt side already had the
better primitive: the local/Plex artist merge keys on
`normalize_artist` (diacritic fold — PARITY-DEBT #7, already fixed for the
Artists tab) rather than a raw lowercase, and the quality badge comes from
the pair every other local surface uses.
And the reason this could not ship as just a feed branch: `feed_track_to_queue`
did not look at the row's SOURCE. A local track carries its `local_tracks.id`
— a small integer that is ALSO a valid Qobuz track id — so clicking one
queued whatever Qobuz song happened to hold that number. The guard cannot be
"does it parse", it has to be the source; local and Plex rows now resolve
through `local_playback::local_queue_track` against a raw-row cache, the
same shape the Tracks tab keeps. A row that cannot be resolved is dropped
rather than played as somebody else's track, and the regression pins both
arms.
cc
The reader had been there the whole time — `theme_qt.rs` builds
`AutoSource::Image` from `auto_theme_image_path` — with no way for a user
to write it, which made "Custom Image" a source you could select and never
supply. Adds the native picker (rfd, through the `settingsString` action
seam the folder chooser already uses), the "Detected: <desktop>" hint from
the shared detector, and the experimental caveat. Persisting the path also
sets source=image before regenerating, because the theme re-reads the prefs.
No new msgids: all four are already in the eight catalogs.
Also deletes eight header comments that were false, each verified against
the code before removing it:
- LocalLibrarySettings: "no native folder picker" (library-pick-folder
uses rfd) and "LibFolderEditModal not ported" (it is, and mounted at
SettingsView.qml:330)
- OfflineSettings + settings_qt/offline.rs: "Check now not shipped" (the
button is right there)
- DeveloperSettings: "in-app log viewer not ported" (LogViewerModal.qml)
and "export always without auth" (the credentials switch is inline)
- PlexSettings: "the PIN flow is not ported" (Generate/Link/Copy code and
Open sign-in all present)
- settings_qt.rs: "no JACK in the POC build" (the banner is in
AudioSettings.qml), plus the wizard and settings export, both shipped
- tray_qt.rs and auth_qt.rs: "the Qt port has no session_persist module"
— false since the queue restore landed; this port flushes once after
the event loop instead of from each quit handler
A "not ported" note that outlives the port is worse than no note: the last
round of recon believed two of these and planned around them.
cc
`system_notifications` was written by Settings and read by nobody: the
shared `qbz_media_controls::show_track_notification` had one caller in the
whole workspace and it was the Slint build's. The switch shipped on by
default and did nothing.
Ordering is load-bearing. The de-dupe runs FIRST — `push_now_playing`
reaches here from `refresh_now_playing`'s 23 call sites (resume, seek,
quality patch, every republish) and only a changed track id may pass — then
the pref read, then the peer guard inside the spawn, so a track a remote
QConnect renderer is playing is never announced.
The toast is NOT downstream of the MPRIS handle: a machine with no
session-bus MPRIS still has a notification daemon, and the reference
likewise raises it outside its `if let Some(mc)` block. Hanging it off that
early return would have made the switch dead again for a smaller audience.
Cover resolution differs from MPRIS on purpose: the notifier decodes by
CONTENT, so a cached copy is fine and saves a re-download, while a widget
resolves file:// through the mime database and rejects the cache's
extension-less .img. That split is the B11 rule.
cc
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
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
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
The guard had its predicate backwards. `album_id.parse::<u64>()` rejected
the four-in-five Qobuz albums whose id is alphanumeric ("vy32kidwrer67",
114 of the 139 ids in the captured fixtures) while admitting local library
row ids, which are exactly what it claimed to be blocking.
A rejected open returns before touching the bridge, so the modal kept
whatever document was there: the previous album's, or — once the QML
started holding its overlay until the ids matched — an empty card. Neither
said a word above debug level, which is the whole of "only the first album
info of a session works".
The published document now stamps `albumId` with the id that was ASKED
FOR rather than the one /album/get echoed back. The QML compares those two
with exact string equality, so any divergence blanked the card silently;
a mismatch now logs instead.
Regression tests cover the four real alphanumeric ids, the barcode-shaped
minority the old guard did accept, and the local/Plex keys the guard
exists for.
cc
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
Restored queues and recently_played carried whatever variant the building
surface picked — 50px JPEGs measured in the wild — into the now-playing
bar, MPRIS, Discord and the immersive big slots. The np feed rewrites
sized Qobuz covers up to 600px; big slots (immersive panels, split,
preview overlay) resolve per size bucket through npArtworkPathLarge with
one re-resolve per tier crossed and never on shrink.
Registry-miss arm (restored sessions carry no ImageSet): rewrite to the
FULL requested bucket with a session negative cache, so a pre-mega album
pays one 404 per session instead of one per track change. And the mega
tier's wire suffix is the literal _max.jpg, not _3000 (404 measured
against the live CDN) — qobuz_cover_at_px now accepts and emits _max.
Local covers gain an on-demand 1600px tier (never upscaled); Plex big
slots tokenize at 1024.
km
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral
Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven
ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed
by the Viz16 stream. Scene rows live in the header view menu (no Off row);
g cycles the ring. A fresh immersive open defaults to Split > Lyrics
(owner ruling).
OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as
type buf and type buf"): every ShaderEffect fragment with its own uniform
layout now ships a paired vertex stage declaring the identical block —
the shared spectrum.vert only ever linked under Vulkan.
RHI hardening: render pass descriptors on the ping-pong targets (the
Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the
backend (!== OpenGL), ribbon sub-uploads carry setSourceSize.
km
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
Slint writes #RRGGBBAA, Qt parses #AARRGGBB, so a literal copied verbatim does
not come out the wrong colour — it comes out transparent, because the alpha byte
is whatever the red byte used to be.
Seven sites across five modals. Every faked 32px drop shadow in the port was
#00000080 (alpha 00), and the scrims of LogViewerModal and LibFolderEditModal
were #000000bf, so those two modals had been opening with no dark backdrop at
all. The rest of the tree already spells the same scrim #bf000000.
Found by grepping for 8-digit literals of the shape #00xxxxxx. The gradient
stops in MixArtwork and LocalAlbumView that are legitimately transparent, and
the comments in the scene components that warn about this exact trap, were
checked and left alone.
cc
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
265 SVG flags (1.1 MB) plus the upstream MIT licence and attribution in
crates/qbz-qt/third-party/. Kept as its own commit so the asset drop can be
rolled back or re-sourced without touching the scene code that consumes it.
cc
An asynchronous Loader plus a router-level skeleton was meant to fix the dead
click on navigation. It made the app markedly worse and is reverted whole.
Async incubation is TIME-SLICED: it yields to the UI thread between slices, so
it trades a short freeze for a much longer wall-clock build, and the Loader
stays in Loading until the entire tree exists. For Discover > Home that is all
413 cards -- a one-second freeze became a five-second skeleton on the most used
route in the app. Measured after the fact, which is the mistake; a change that
moves WHEN something heavy is built has to be timed on the busiest route before
it is called a fix.
The placeholder is gone with it. It drew the same two text lines and row of
five cards for all 40 routes, so the swap moved everything -- the exact jump a
skeleton exists to prevent.
The dead click is real and remains unfixed. The durable fix is not here: stop
rebuilding these views on every navigation (keep the top-level tabs alive) and
build the heavy views incrementally. The reasoning is kept in the file so the
experiment is not re-run.
cc
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
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
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
The docs tree is not versioned, so the release-facing summary of the
repaint-pulse campaign lives here as a draft TO-DO for the first Qt
release, with a discreet pointer from the pulse property itself.
km
Restores the eq-bars indicator behind the existing
play-indicator-animation pref (the toggle already lived in
AppearanceSettings; the pref was plumbed but unconsumed). The bars ride
the shared shell pulse, so they cost zero extra presents — the
affordable version of what used to be a display-rate animation.
Pref OFF keeps the static accent pill + ring, per TrackPlayCell.slint.
km
- AlbumReactivePanel: drop the eight 100ms Behaviors on 30 Hz data (a
retriggered Behavior animates at display rate and every animation
frame is a full-window present), and read the settle — not the
publishing bridge list — in the energy formulas (the .length guard
re-evaluated the visual bindings on every FFT publish).
- LyricsSyncEngine: the fast cadence rides the pulse; the 250 ms idle
re-check keeps its own timer.
- EqualizerBars: the phase accumulates on the pulse and the 'active'
gate is host-provided (invisible subtrees still dirty the window).
Measured on the owner's machine, immersive reactive panel open:
132 -> ~33 presents/s, 83% -> ~25% GPU.
km
Qt Quick has no partial redraws: any dirty item presents the WHOLE
window, and on the owner's hybrid laptop each full-window present costs
~1.2% GPU flat (KWin composite + dGPU scanout; area-independent, while
the app's own render is 0.08 ms/frame). The bill was presents/s, and
the shell ran unsynchronised ~30 Hz clocks plus two leaks:
- QbzShell.pulseMs: ONE Rust-driven 33 ms edge that every continuous
animator ticks on (knob QBZ_PULSE_MS). VizSettle now stashes on
publish and applies on the pulse (its 16 ms interpolation timer is
gone); AmbientField and ImmersiveAtmosphere ride the same edge.
- VizSettle writes NOTHING while not live: the always-mounted but
invisible immersive panels were dirtying 28 bar rects per FFT
publish (named via QT_LOGGING_RULES=qt.quick.dirty=true).
- AmbientField renders inline again: ShaderEffectSource
scheduleUpdate() double-presents (minimal repro fbo_repro.qml in the
investigation folder).
- Background mode 2 (ImmersiveAtmosphere) composites its four drifting
layers + base + dim + scrim in ONE opaque ShaderEffect
(atmosphere.frag, baked by build.rs); numeric parity vs the Image
stack verified (atmo-shader-parity-check.py, delta 0.0). The
four-Image stack stays as the software/failure fallback.
Measured on the owner's machine: 93-97% -> 25-26% GPU, ~34 W -> 12-13 W,
~33 presents/s in both background modes. Full story:
qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/GPU-COST-INVESTIGATION.md
km
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
Closes PARITY-DEBT #83 and #104, and unblocks the immersive shader scenes.
THE TIER. Slint resolves one bool, use_gpu_renderer, and hands it to three
features: the shader scenes, the dynamic background and half of reduce-motion.
This port had none of it, which is why the scenes could not be offered — off
the GPU tier they render black and nothing could tell. QbzShell now carries
rendererApi, gpuTier, shaderScenesAvailable and appBackgroundAvailable, all
written from one latch. Only QML can source it: QRhi picks the backend after
QGuiApplication, so the pre-init code knows only what it asked for.
Per-item GraphicsInfo.api stays where it is. "Can this item draw?" and "should
this feature be offered?" are different questions, and routing the first
through Rust would make every cover wait on a round trip.
SHADERS ARE BUILT NOW. build.rs bakes every .frag/.vert to .qsb with ten
variants and the batching rewrite. They used to be baked by hand and committed
carrying no GLES variants at all, so any GLES context failed them with "No
GLSL shader code found" — which is exactly how the NPB Large visualiser went
blank while this branch was being tested. Adding a scene is now dropping a
.frag in the folder.
GPU SELECTION enumerates real devices through Vulkan and lists them by model,
never by class: a machine with no discrete GPU can no longer be told to use
one. Legacy class keys still resolve, but only against hardware that exists.
Picking a non-default device implies the Vulkan backend, which the row's own
description states in all eight locales — measured as the only route that
keeps a working context: DRI_PRIME lands on llvmpipe and the NVIDIA PRIME
pair lands on OpenGL ES.
The renderer row gains the auto-revert sentinel and the frame-liveness
watchdog it shipped without. The sentinel arms only for a choice that truly
forces a backend, and liveness means frames rendered over a window, not merely
a window that opened.
Also fixes the kiosk toggle overwriting the tier half of reduce-motion.
Verified by 312 tests, both QML audits, the offscreen smoke, and a probe
against the real hybrid GPU: both devices enumerate by name and the stored
preference applies.
cc
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened
from Settings > Audio. It probes the audio stack and renders per-distro
remediations, enumerates DACs with a manual node.name escape hatch,
generates the PipeWire / WirePlumber snippets to copy, and plays four
tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated
read-back. Read-only: it never writes a system file or runs a command.
The logic was unreachable from Qt — every public function in
qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals.
It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core,
which returns plain data. qbz-dac-wizard itself is left untouched.
Rust owns everything it computes plus `open`, so the reset and the probe
are one act; QML owns the step navigation and the four progress
checkboxes, which is how the reference splits them too.
Two deliberate divergences from the reference: the row sits at the end of
OUTPUT rather than BIT-PERFECT, and it is offered on every backend
instead of PipeWire only — someone on ALSA direct is exactly who needs
the walkthrough.
The green glyphs use the accent tint, not success: success has no qrc bake
directory, so a degraded tint set would render nothing at all. Every
success-toned text keeps theme.success (#3fae6a verbatim).
Verified by 309 tests, both QML audits and the offscreen smoke, plus a
forced-route probe that mounts Settings and walks the wizard's steps —
the standard smoke only mounts Home, so it is no evidence for a modal.
The modal was also driven and screenshotted over VNC.
cc
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
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
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
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
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
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
Opening album A's info, closing, and opening B's left A's fetch in
flight with nothing stopping it from publishing A's document over B's
loading state — the modal rendered A's credits inside B's page (smoke
catch, 2026-08-10). The carousel rows in album_qt.rs already carry the
"late replies are generation-guarded" pattern; the modal now does too:
a monotonic open counter gates every publish, open() clears the stale
document with the loading flag, and the QML overlay additionally holds
until the published document's albumId matches the requested one.
km
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
Two card-data gaps on the album page's bottom rails:
- "Listening suggestions" (/album/suggest) rendered no artist line:
the Qt mapping read only the flat `artist` field, while Slint's
album_map::album_artist falls back to the artists[] array (main-artist
role, else first entry) — the shape /album/suggest actually returns for
some albums. The fallback is ported as card_artist.
- "Similar albums" (Last.fm row) showed no genre in the hover overlay:
AlbumReco carried no genre at all. build_album_reco has the resolving
Qobuz Album in hand, so the field now exists (serde-defaulted, so
cached reco JSON keeps working) and is mapped through. Slint's
external_reco::album_card can map it too after the next merge — its
checkout is a different branch and is untouched here.
km
Every flyout entry (Discover > For You, Library > Albums, Local >
Folders...) navigated to the section but landed on its default tab: the
port handed the tab to the mounted view through a depth-capped scene
search (NavFlyout.findTabHost, cap 7), and the ContentRouter extraction
(kiosk port D3) added a mount level the walk never reached — flagged as
a latent desktop regression in the kiosk audit (2026-08-02
AUDIT-REPORTS.md, finding 11) and confirmed by smoke test.
The tab now travels the way this codebase crosses documents: the
bridge. NavFlyout.activate calls the new QbzShell.navigateToTab(view,
tab), which publishes navTab/navTabView/navTabSeq, and a Binding in
ContentRouter applies it to the mounted view's activeTab — the same
pattern as the MyQBZ kind discriminator. The seq counter re-applies
on a repeated click of the same entry, the navTabView guard keeps a
cross-view request off the outgoing view, and a plain navigateTo clears
the request so section navigation never drags a stale tab along. A
click for the already-mounted view skips navigation (no duplicate
history entry) and re-applies the tab live.
Verified by smoke: cross-section, same-section, repeated-click
and plain-navigation cases all land where Slint lands.
km
The reference leaves Streaming Quality out of settings resets on
purpose (it is a UI-only pref owned by neither domain store); this port
forced it back to hires_plus, silently re-enabling hi-res streaming for
users on metered connections.
cc
Hands out the connectivity actor's existing public request_recheck so
the row probes immediately instead of waiting for the next scheduled
check — the passthrough was the whole gap.
cc
The export row hard-coded include_auth=false, so the 'portable bundle'
the UI described could never sign you in — the reference's single
checkbox (default OFF) is now wired through, as session state rather
than a persisted pref.
cc
Dragging rows onto the queue panel now lands them AT the pointed slot
(insertion line included) instead of appending: add_track + move_track
compose the position locally (QueueManager has no insert-at-index and
the manual-block adds would corrupt manual_next_count), and the
QConnect controller path routes through CtrlSrvrQueueInsertTracks'
insert_after so the position survives to the peer. Multi-row drops keep
their own order. Dropping onto an EMPTY queue raises a one-shot
'start playing?' prompt rather than hijacking a running queue or
leaving the user one click short.
cc
The Authorize half of the Plex settings: pin/start + pin/check polling
against plex.tv over the shared qbz_plex protocol (the stable per-install
client id is reused across both calls or the check never sees the
authorization), manual-token mode, and the Check-connection ping that
stamps the server's machineIdentifier onto the cache. The panel gates
Connect on a pure is-local-address predicate while the user types,
matching the reference.
cc
Per-folder settings modal (alias, enabled, network/FS-type override),
change-path and a native folder picker feeding the same add path as the
typed field, plus the shared double-confirm host used by the destructive
rows. Panel refresh no longer blocks on dead network mounts (accessibility
probes run off the publish path), and removing a folder prunes its albums
from Recently Played so the rail stops pointing at deleted database rows.
cc
The Developer > Share-logs handoff (open::that on the log file) becomes a
filterable in-app view: level filter + substring search run Rust-side over
qbz_log's ring and only the survivors cross the bridge (one document, max
500 rows), with auto-tail, copy-all, a GitHub-ready <details> bundle and
paste upload. The bundle header now reports the real audio backend via
settings_qt::current_backend_label.
cc
Three Slint-parity pieces that share the chrome surface:
- macOS menu-bar tray (NSStatusItem, tray_macos.rs) ported from the
reference — the K2 'macOS is a new implementation' ruling read the
reference backwards (it avoids muda entirely), and its absence made
close-to-tray quit the app on the Mac. Plus the custom-chrome window
mode (macos_chrome.rs): Qt 6.9 ExpandedClientAreaHint /
NoTitleBarBackgroundHint instead of fighting QCocoaWindow's style-mask
recompute, traffic-light centring, and the SafeArea inset in HeaderBar.
- Appearance rows that rendered, persisted and drove nothing now push
live: hide/show title bar, window-controls cluster side, track in the
window title, invert swipe navigation, auto-theme source selection
(wallpaper/image/system) with regeneration on source change.
- use_system_title_bar now defaults per-OS (custom chrome on macOS),
matching the reference — the hard-coded 'true' also leaked into the
Slint build through the shared ui_prefs.json.
cc
Shared button-with-icon control (used by the queue panel, Plex settings,
the log viewer and the library folder editor) plus the external-link
icon in every theme tint.
cc
This host's path to its first resolver drops the second of two parallel
DNS queries on one socket, so every getaddrinfo (parallel A+AAAA) stalls
for the full 5s RES_TIMEOUT before retrying — the multi-second
shuffle-start tax after the app idles, confirmed by the CMAF init timing
(send=5449ms, body=0ms) and reproduced app-free with getent + raw UDP
probes (parallel loses AAAA; sequential passes in 0.19s). Setting
RES_OPTIONS first in main makes glibc serialize the lookups
process-wide. No-op on non-glibc resolvers.
km
format_display branches on cfg!(target_os = "macos") — glyphs joined by a
space there, words joined by " + " everywhere else — but the test pinned only
the non-mac spelling, so it could never pass on the Mac. It failed the first
time the suite was ever run there.
The code was right; the assertion was one-sided. Branch it, and keep the
modifier-free cases unbranched since those are what actually pin the glyph
table the test is named for.
cc
Second one the Mac build caught and Linux could not: the type resolved, but
`new()` did not exist — it is gated on `NSThread`
(objc2-foundation-0.2.2/src/thread.rs:210-213), leaving only the unsafe
`new_unchecked`. That is precisely the wrong half: the point of the call is
to ASK whether we are on the AppKit main thread and no-op when we are not.
cc
The Mac build caught it on the first try, which is exactly what it was for:
`use objc2::MainThreadMarker` does not resolve — in the objc2 0.5 /
objc2-foundation 0.2 generation the type is exported by FOUNDATION
(objc2-foundation-0.2.2/src/lib.rs:152, ungated); it only moved to the objc2
root later. The reference imports it from the same place
(crates/qbz/src/tray/macos.rs:38), which is where this should have been read
from in the first place rather than assumed from a newer API.
The direct `objc2` dependency goes with it: nothing here names a type from
that crate.
cc