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
insert_track used INSERT OR REPLACE. SQLite resolves that conflict by DELETING
the old row and inserting a new one, which hands out a fresh rowid.
playlist_local_tracks.local_track_id is a foreign key onto local_tracks(id), and
foreign keys are OFF in this database (the only pragmas set are journal_mode and
synchronous), so the cascade never fires: the playlist row survives pointing at
an id that no longer exists and the INNER JOIN that reads it returns nothing.
The scan re-inserts every file — there is no mtime skip — so every library
rescan silently emptied Qobuz playlists of their local tracks.
The conflict that fires is not the obvious one, which is worth recording: the
table's UNIQUE(file_path, cue_start_secs) does NOT catch an ordinary track,
because cue_start_secs is NULL and SQLite treats NULLs as distinct. What catches
it is the partial index idx_tracks_file_nocue. Both identities are handled.
The row is now looked up and UPDATEd in place. Reproduced on a scratch database
before the fix was written, and the regression test was confirmed to fail
against the old code path.
First-class LOCAL playlists were never affected: local_playlist_tracks stores
local_path, not an id.
cc
write_album_tags_to_files called set_artist(album_artist) unconditionally on
every file. Two irreversible outcomes, and a direct write has no undo:
- On a various-artists release the editor pre-fills the artist field from
compute_track_artist_match, which returns None the moment two track artists
disagree — so the field renders EMPTY and that empty string was written over
the ARTIST of every file in the release.
- With a non-empty album artist, every guest credit was replaced by it.
The DB half of the same save was already careful: update_album_group_metadata
renames the track artist only where it still equals the value that was uniform
across the album. The file writer now agrees with it. A pre-pass reads the prior
artist from the files themselves — they are the ground truth about what is being
overwritten — and should_rename_artist renames only on an exact match with a
non-empty new value. ARTIST is track scope and the editor has no field for it.
The pre-pass is skipped when the album artist is blank, so the common path costs
nothing.
cc
In the DEFAULT (folder) grouping mode, a box set laid out the way our own
multi-disc guidance recommends — Disc NN - {subtitle}/ — showed up as one album
per disc.
album_root_dir climbs past a directory only when is_disc_folder accepts it, and
that predicate required the name to consist ONLY of disc tokens, digits and a
short modifier list. "Disc 01 - TV Series Soundtrack #01" tokenises to
["disc","01","tv","series","soundtrack","01"], has_extra_words hits "tv", and
the climb stops — so the group key becomes the disc directory and a 13-disc set
becomes 13 albums.
is_disc_folder now also accepts a name whose LEADING token is a disc designator
followed by a separator with whitespace on at least one side. That is disjoint
from the issue #147 false positives, which are album titles that END with a disc
token ("Now 75 - CD1", "100 Popular Classics, Disc 1", "Relaxation Disc1") —
their regression tests pass unchanged. The rule stays narrow deliberately:
"CD-Rom Favourites" and "Disc Jockey Anthems" are still album titles, because an
accidental merge of two releases is worse than the split being fixed.
The disc NUMBER is gated on the same predicate, so a box set with no DISCNUMBER
tag now recovers it from the folder name too.
Sidecar lookup follows the key, so moving the key would have orphaned every
.qbz.json saved while a disc was still its own album — album title, artist, year,
genre, catalog and all per-track overrides, silently. apply_sidecar_override now
tries the album root and then the file's own directory, and the CUE path goes
through the same function instead of its own single-directory copy.
Existing installs see the merge after a rescan; album_group_key is stored at
index time.
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
The segment feeder spawned by play_track kept downloading to completion
after the user switched tracks, holding the link: a shuffle&play pressed
mid-stream measured 5441 ms for the new track's init segment (vs
423-760 ms on an idle link) because the init's connect/TLS/TTFB queued
behind ~15 MB of in-flight segments of a track nobody was listening to.
begin_play (every superseding intent: play_*, stop) now cancels the
registered feeder cooperatively via a watch flag the feeder selects on;
the in-flight request is dropped immediately. The abandoned buffer is
deliberately NOT errored or completed at cancel time, so the outgoing
engine keeps playing what is already buffered until the audio thread
installs the new source; the buffer is sealed (complete) once the new
source is in place (or on stop) so a parked reader wakes to EOF instead
of hanging. Cancelled partial tracks are not cached.
km
Diagnoses where slow init-segment fetches lose their time:
send(dns+connect+tls+ttfb) vs body. Landed the 2026-08-10 proof that
the multi-second shuffle-start stall sat entirely in the connection
phase (send=5449ms, body=0ms).
cc
The PlayStreaming arm was the only start path that did not clear
gapless_pending/gapless_request_armed on supersede (Play, PlayDsdDop,
PlayDsdNative, Stop and Seek all did). A pending that outlives its
engine both blocks gapless re-arming for the new track and fires
positionally at the new track's end, swapping current_track_id to a
track whose engine no longer exists — observed as the queue stopping
dead with a full queue. Also adds [gapless-trace] set/clear logging at
every lifecycle point to pin down the surviving X -> X self-transition.
cc
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
Measured, not assumed: under XWayland the same Qt hint DOES set
_NET_WM_STATE_ABOVE and KWin honours it, so always-on-top is not impossible —
it is only impossible for the app to ASK for it on Wayland, where no such
request exists in the core protocol.
So the app stops claiming it can pin itself and instead ships the thing that
works: a window rule matching the miniplayer by its stable, never-translated
title, narrowed by wmclass so no other app's window picks it up. The main
window is titled "QBZ", which is why the title match is exact rather than a
substring.
OPTIONAL and never applied by an installer. It is the user's window manager
and their configuration; a package that silently rewrote kwinrulesrc would be
a worse citizen than one that ships a file and a sentence of documentation.
The rationale, the two rejected routes and the import steps are in
qbz-nix-docs/qt-frontend/2026-08-04-miniplayer-keep-above-kwin.md.
cc
The row rendered, persisted and drove nothing, which is the exact defect
class the miniplayer/tray contract existed to end. The owner named the
behaviour they want: close puts the app in the menu bar AND drops the Dock
icon; minimize still goes to the Dock like any app.
So only the CLOSE path touches the policy. The wiring point is
`QbzTray.setWindowShown`, which QML calls from exactly two places —
`hideToTray` and `showFromTray` — so this needs no third visibility owner and
cannot drift from the real window state.
Hiding drops the icon only when the setting is on; SHOWING always restores
it, with no setting check. A user who turns the option off while the app sits
in the menu bar must still get their icon back, and restoring an icon that is
already there costs nothing.
NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind
cfg(target_os = "macos"), and a cross-check dies in the C build scripts for
want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2
instead — which immediately earned its keep: `NSApplicationActivationPolicy`
lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication
feature does not pull it. The three features here match the reference's own
proven set. First real check is a build on the Mac mini.
cc
The owner asked whether the remaining ~1 s was the sweet spot. It was not —
it was an artefact of fixing this with a push instead of a getter.
MPRIS `Position` is read on demand, so what matters is the answer when the
client ASKS, not when the app last spoke. The backend stored a snapshot, so
the answer was only ever as fresh as the last push, and the only lever left
was polling faster — which would have meant a second timer the perf rules
refuse.
Storing the instant alongside the value removes the tradeoff: the getter
returns `position + elapsed` while Playing, exact to the microsecond however
rarely the app pushes. The 1 Hz push stops being the clock and becomes what
it should have been all along — a re-anchor against drift.
Every write re-stamps, including a status change that carries no position:
without that the base would keep running across a pause and the clock would
count while the music did not.
cc
The owner's observation is what separated the two halves of this: "el
contador del seekbar del widget solo empieza a contar hasta que lo abro."
A client reads Position ONCE, when it opens, and extrapolates from there —
it does not poll. So two different things were broken and only one was fixed
by keeping the stored position fresh.
Fresh storage fixes what a client sees when it OPENS. `Seeked` is the only
thing that reaches one that is ALREADY open: it tells the client to stop
extrapolating and re-read. Without it, a seek moved the music and the widget
kept counting from where it had been.
So the seek path emits Seeked instead of re-asserting the playback state,
which was the previous commit's guess at a cure inside an API that had no
signal. `mpris_server::Signal::Seeked` was there all along.
Defaulted on the trait to `set_position`, so a backend without the signal
still gets the value right on the next read and no other consumer changes:
cargo check passes for qbz-app and qbzd.
cc
The owner measured QBZ at 4:03 with Plasma's widget stuck at 0:14. The
earlier seek fix was right about seeks and wrong about the shape of the
problem: the position was not drifting, it was FROZEN.
MPRIS `Position` is a read-on-demand property — the spec deliberately keeps
it out of `PropertiesChanged`, so a client reads it when it wants it. The
Linux backend serves whatever it last stored, and it only ever stored a
position when `set_playback` carried one: at a track start (zero) and at a
play/pause edge. Between those, every read returned the same stale number
for as long as the track ran.
This is in the SHARED crate, so the shipping Slint build has it identically —
nobody had watched the widget's clock there.
`MediaIntegration::set_position` stores and emits nothing, so the Qt poll
loop refreshes it on the tick it already computes the position for: one
channel send per second, no bus signal, so it does not become the per-tick
work the perf rules refuse. The method is DEFAULTED, so no other consumer has
to change — `cargo check` passes for qbz-app and qbzd, and the Slint frontend
needs no edit (it cannot be built here, per the qbz-ui wall, so it is
verified statically as R4 requires).
cc