A new top-level Settings section, Import / Export, gathers everything that
moves data in or out of this install:
- SETTINGS PORTABILITY: the settings bundle export moves here from
Developer, with a note on what the bundle does not hold (favorites,
playlists, blacklist, purchases).
- BLACKLIST: export the blocked artists and albums as a JSON one user can
hand to another; import merges additively and never touches the enabled
flag (qbz_app::settings::blacklist_portable, frontend-agnostic).
- ACCOUNT MIGRATION: create a snapshot while signed in with the old
account, then migrate it into the account signed in now, with the local
profile copy and its three opt-in toggles (media servers, scrobblers,
listening history, all on). The disclaimer states that subscriptions and
purchases do not move. Progress and results are status lines in the
settings document; the favorites cache re-warms and the sidebar reloads
after a run, the local half asks for a restart.
Also: the header shows a translated "Member" for accounts without a
subscription, and the login screen says what a member can and cannot do.
50 new strings in all eight catalogs.
cc
Preserve local playback when it is authoritative, make renderer conflicts explicit and persist the selected policy. Filter unsupported queue rows, restore portable Qobuz identities after disconnect, and fence takeover commands to avoid stale queue and playback loops.
cx
Plain-Item overlays get no modal grab, so a MouseArea that swallows
presses still lets the wheel scroll the page underneath (the rule and
its wording live in DiscoverConfigModal.qml). Every scrim and panel
swallow across the modal set now accepts the wheel; the modal Popups
(TrackInfo, CastPicker, AlbumInfo, QconnectDev, LocalMediaInfo) already
had the Overlay grab, and CoverLightbox — a non-modal Popup — gets its
own.
cc
Add capability-aware enablement, exact control selection, diagnostics, live volume synchronization, and translated unsupported-state feedback to the Qt settings surface.
cx
Settings > Integrations > PRIVACY: a Listening history toggle (default
ON; the state lives in the per-user listen_meta, so it follows the
account) and a confirmed Clear (DELETE + VACUUM). Strings in all eight
catalogs; README names where the data lives.
ScrobblerSettings::history_read_allowed(service) = *_active(): the
scrobbling switch now governs reading a service's history for
Recommendations as well as sending to it, and its Settings text says
so. recommendations_qt uses it instead of *_is_authed().
cc
The third sibling of the Linux ksni item and the macOS NSStatusItem, wired into
the same `tray_qt` seams and dispatchers, so a tray action behaves identically
on all three. Close-to-tray needs no QML change: `Main.qml:786` already keys on
`trayLive`, which both arms of `init` publish.
The menu is the SAME menu. The plan's C ABI carried no labels, which would have
shipped an English-only menu in an app with eight locales; Linux and macOS
already share five `qbz_i18n` keys, so the translated strings are passed in
from Rust and no new string is introduced.
Win32 details that are load-bearing rather than incidental:
- NIF_SHOWTIP. Version 4 SUPPRESSES the standard tooltip unless asked, so
without it the icon appears, every track sets the tooltip, and nothing ever
shows on hover.
- The NIM_SETVERSION result is checked and creation fails if it is refused.
The window procedure decodes callbacks as version 4 and nothing else, so a
silent negotiation failure would leave a live icon whose clicks and menu do
nothing -- worse than no icon, because close-to-tray would then hide the
window behind it.
- WS_EX_TOOLWINDOW, not HWND_MESSAGE. A message-only window never receives the
broadcast TaskbarCreated, so the icon would never come back after an Explorer
restart. The window is on the GUI thread, so Qt's own loop dispatches it.
- LoadImageW without LR_SHARED returns an icon this process OWNS; it is
destroyed on teardown, while the LoadIconW system fallback is not.
- Failed creation gives back the window and the icon instead of parking them
for the process lifetime, and create is idempotent.
- NIM_SETFOCUS after the menu closes, which the Shell asks for and matters most
when it was dismissed with Escape.
Every mutation is marshalled through `tray_bridge::ui`, exactly as the macOS
theme arm is. The publishers run on whatever thread playback pushed from -- the
1 Hz poll lives in `crate::spawn` -- while this tray keeps process-global C++
state and talks to a GUI-thread window; calling straight through was a data
race, not merely a documentation mismatch. Linux needs no hop because ksni's
handle is a thread-safe channel.
Strings are truncated on the Rust side by UTF-16 CODE UNITS, not by `chars()`.
The buffers are sized in `wchar_t`, an astral character costs two, and
`wcsncpy_s` with `_TRUNCATE` would happily keep a high surrogate whose partner
was cut -- malformed UTF-16 handed to the shell.
The tray-icon-variant row is hidden on Windows: the notification area draws the
executable's own icon and Windows owns light/dark, so the four choices would
render, persist and change nothing.
cc
Three settings and menu entries offered Windows something no code behind them
could do.
DSD playback was opened to WASAPI in fc0d35e29, written before the phase B
spike. The spike then measured this DAC refusing 176400, the DSD64 DoP
carrier, so DoP over exclusive mode lost its target; and both non-convert
modes are cfg(target_os = "linux") in the audio thread and answer "DoP
playback is Linux-only" with a stream error. The row could only offer two ways
to fail. Back to ALSA-only. DSD still plays on Windows, converted to PCM.
Exclusive mode became visible the moment phase B published backendIsWasapi,
but the selection arm keys on backend_type == WasapiExclusive and never reads
exclusive_mode. A live toggle that changes nothing, directly under the
BIT-PERFECT header and directly above the thing being measured.
Open audio CD is dropped on Windows: the CDDA backend is mod unsupported there
and answers NoDrive to everything. Open SACD image stays on every platform --
it opens a file, not a drive, and hiding it would have been a bug.
cc
Adds isWindows, traySupported, systemNotificationsSupported and
rendererSelectable to the settings document, so Settings QML keeps reading
ONE document the way it already does for isMacos. The two any(...) lists
flip to include Windows when plan A-2 T3/T5 land; they point at that line.
Then the Audio tab, measured on the box with the tab open:
HiFi Wizard was live on Windows. qbz-dac-wizard-core generates a
PipeWire/WirePlumber config and knows no other stack, so the wizard cannot
finish anywhere but Linux. Hidden, not disabled - a disabled row invites a
bug report. This is not the reference's backend-is-pipewire gate returning:
inside Linux every backend still sees it, per the 2026-08-11 ruling in that
file's own comment.
Reserve DAC is D-Bus org.freedesktop.ReserveDevice1, Linux only. On Windows
exclusive mode IS the reservation; on macOS it is Hog Mode.
Exclusive mode was visible with no backend able to honour it. It now follows
the backend: Linux unchanged (always visible, enabled only on ALSA), hidden
on macOS, and on Windows it appears when Phase B publishes backendIsWasapi.
QML reads an absent doc property as undefined and undefined === true is
false, so this is correct today and correct again the day the field lands.
The DSD row follows the same pair.
Main.qml no longer boots the wizard off Linux, where its row is hidden and
the audio-stack probe would reach nothing.
Linux sees no change: every gate is isLinux-true or an added || on a field
that is undefined there. Five QML audits pass.
cc
Port the Tauri immediate and accumulated offline policies into the active Qt fire path, retain independent scrobbler sessions across Qobuz logout behind a default-on opt-out, and flush queued Last.fm/ListenBrainz plays only when the effective policy permits it.
cx
Add the chained genre, artist, and album browser with persistent layout and tab ordering. Reuse the shared album semantics for grouped versions and disc-aware details, enforce active source filters, and bound foreground detail work so chained searches stay responsive.
cx
Hide the waveform controls and force every now-playing bar back to the classic seekbar while preserving the implementation for a later release. Keep full-screen timed lyrics centered, slightly larger and wrapped without changing the approved typography or fill.
cx
Analyze audible tracks into a progressive 512-bin RMS document, cache complete results, and preserve gapless track boundaries. Render the waveform through a scenegraph item in both player bars behind a default-off preference.\n\ncx
It shipped doing nothing. Measured on the built binary: picking a face
persisted the pref and changed ZERO of 600600 pixels in the settings
label column. The row was a control that lied.
Two measurements say why, and both were run against this Qt build rather
than reasoned about:
- `ApplicationWindow.font.family` propagates through Qt Quick CONTROLS
only. In a standalone scene with a family set on the window, a
`Label` resolved to it and a sibling `Text` stayed on the
APPLICATION font. This tree is 1082 plain `Text` items against 13
that name a family, so the app's typeface IS the application font.
- The application font is read when an item is CONSTRUCTED. Driving
PySide6 against the same Qt, `QGuiApplication::setFont` updated
`QGuiApplication::font()` and left an already-built `Text` on its old
family.
Together those close the door on changing it live, so the ordering in
main() is the feature: FontPreload.qml registers the bundled faces (a
family does not exist until its file is loaded, and `load()` returns
with its FontLoaders already in the database), the application font is
set from the persisted choice, and only then does Main.qml build the UI.
Swap any two of those and the setting silently does nothing again.
The row now says "(requires restart)", the same wording Interface size
two rows above already uses, in all eight catalogues.
"System" resolves to "" and takes no branch, so anyone who never opens
the row gets exactly the font this app rendered before — verified by
capturing the window on both settings: 0 log lines and an untouched
frame for System, 27065 changed pixels for Montserrat.
NOT ADDRESSED, and worth its own decision: those 1082 Text items have
never used the bundled Inter either. `Main.qml` has always named it, but
that only ever reached the Controls, so the app renders in whatever Qt
picks. Making "System" mean Inter is a one-line change and a visible
one, so it is left alone here.
cc
The three media servers sit one under the other and Plex was the odd one
out: a small-caps "PLEX" eyebrow with no brand mark in a 64px band,
against the 44px SourceIcon + 14px DemiBold title the other two use.
SourceIcon already draws the full-colour Plex mark untinted, so this is
the same header, the same metrics and the same glyph pipeline.
NOT folded into MediaServerSettings.qml: that component is one form
twice — address, account, password, connect, sweep — and Plex's body is
a different shape, a PIN pairing flow with a server picker and a
per-library table. Only the header is the same.
One affordance the other two have is deliberately not copied: the
"· <server name>" suffix. PlexFields carries no name — pairing stores a
url and a token — so the row would be permanently invisible. An empty
binding is not parity, it is a dead branch.
cc
`sm: true` made this the ONE select in Settings drawn at the small size,
which reads as a rendering bug sitting directly under the full-size
Album grouping select. The control-alignment standard has one size for a
settings row.
cc
The folder block — toolbar, add-path row, filter, the row-per-folder
table and the scan progress — is the tallest thing in Settings and it
sat at the TOP of Local Library, pushing Albums view, Library > All,
Maintenance, Danger zone and the three media servers off the first
screen. Behind a Manage row, the whole panel fits one screen again.
The shape is the one Blacklist already uses for its manager, and this
view is deliberately its twin: same 64px summary row, same metrics, same
route-recorded-by-the-caller contract.
Nothing about the table changed. LibraryFolderTable.qml is mounted
verbatim, still driven by the settings document and still acting through
the settingsString action keys.
Two things had to travel with it, because SettingsView used to supply
them and a routed page has no access to that:
- LibFolderEditModal, or the per-folder pencil would open nothing,
silently.
- the refresh on entry. A view reached by Back runs no per-view load,
so it asks for its own, where BlacklistManagerView puts its reload.
Verified in a running window: the route mounts, the seven folders render
with their per-row actions, and Back returns to Local Library with the
section intact.
cc
Row wiring is untouched — every block moved verbatim and nothing here
changes what a control does. What changed is the grouping, so the group
order can no longer be read out of AppearanceSettings.slint:
THEME the picker and the rows that exist only for it
TYPOGRAPHY & LANGUAGE Language, Interface size, Font
NAVIGATION was LIBRARY & VISUALS minus the visual rows,
plus the three Purchases/menu rows
SEARCH new: Intelligent Search came out of THEME,
Immersive search out of TYPOGRAPHY & LANGUAGE
PLAYER & VISUALS was PLAYER VIEWS, plus the visual rows
WINDOW WINDOW TITLE + TITLE BAR + WINDOW CONTROLS
SYSTEM INTEGRATION NOTIFICATIONS + SYSTEM TRAY
RENDERER unchanged
Four rows are now ABSENT rather than rendered-and-disabled, because a
control that cannot do anything should not be on screen: Purchases in
title bar (Show Purchases off), Window controls position (system title
bar on), Close to tray and Tray icon variant (tray icon off). Each keeps
its stored value, so turning the parent back on restores the answer
already given.
The My QBZ row loses its fixed logo preview: it renames the hub and
never picked an icon, so the image was decoration in a control column.
Verified in a running window, English and Spanish.
cc
The nine panels were siblings gated only by `visible:`, so every mount
of Settings built the 30 KB Appearance panel, the 19 KB Plex panel and
the rest in order to show one of them. Measured before the change,
Settings was the slowest route in the app.
A Loader with `active:` is the fix that shipped for the Discover tabs
and it is the fix here. Measured after: built 326 -> 167 ms, to-idle
392 -> 224 ms.
The two overlay modals (LibFolderEditModal, DacWizardModal) are still
built unconditionally and are the next item of the same kind; they own
their own open state, so gating them is a separate change.
cc
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.
Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.
CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.
THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:
1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
not in the QML module. It resolves fine on disk, compiles nothing,
and blanks the WHOLE settings page at runtime. All three existing
audits passed. `qml_module_registration_audit.py` is the missing
check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
settings DOCUMENT, unlike Plex's toggle which reads a bridge
property, so every mutation has to republish it — connect, enable,
disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
`local_rows::badge_source` had never been taught the two words and
folded them into `_ => local`. Not cosmetic — that string is what the
source chips filter on.
`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.
END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.
428/428.
cc
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.
Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:
1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
table. Slint can derive it because its entries carry the tab
(Favorites { tab }); here an entry is a bare route id, so a derived scope
would read "library" for all five Library tabs and pour the Albums grid's
offset into whatever tab mounted. Letting the reporter name its own scope
buys tab granularity for free, and the mismatch case degrades the right way:
an unmatched scope restores NOTHING instead of restoring the wrong thing.
2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
contentY to 0 whenever its model is replaced, and several pages assign their
model from a binding whose inputs arrive over the network after the mount —
the album page rebuilds listCells from three deferred rails, so it hands its
ListView a new array up to four times in its first seconds and snaps back to
the top over a restore that already ran correctly. The offset is held and
re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
drag or a flick raises `moving`, and from then the position is theirs).
Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.
cc
Settings > Appearance, OFF by default. With it on, clicking Discover lands on
Home and clicking Library lands on All, instead of only opening the section's
flyout and waiting for a pick.
The rule lives in NavFlyout because the catalog does: "the first tab" is
`entries[0]`, and routing it back through `activate` means the My QBZ section —
whose entries are plain views carrying no tab — needs no special case. Both
hosts call it from their row's click next to the open they already do, so the
sidebar and the title bar cannot drift.
The pref rides `settingsJson` like the two Purchases toggles beside it, so
nothing is wired beyond the write and the republish every settings toggle
already does. The read is a guarded parse: a bare JSON.parse in that binding
throws on the pre-publish frame and would take the whole nav catalog — and
therefore both hosts — down with it.
Eight locales. Release build clean, offscreen smoke green.
cc
The last unported row of Settings > Audio. Read-only by design: a Hz
dropdown is the documented root-cause trap, because it promises rates the
app cannot request when Qobuz sells four discrete tiers. The value is
Rust-composed from the probe and the row hides on an empty summary; the
fallback caveat renders only when the probe fell back to the common set.
No new msgids -- both strings already exist, translated, in all 8 catalogs.
Six refresh triggers, one more than the reference: startup, the toggle, a
backend switch, an output-device change, reset-to-defaults, and the device
refresh/release button. That last one is Qt-only and the easiest to miss --
it is the hotplug case, where the hardware behind a "System default"
selection changes without the selection changing.
Ordering is persist -> refresh -> publish, and the refresh goes BEFORE
apply_audio rather than merely before the handler's closing publish:
apply_audio publishes on its own way out, an earlier serialize that would
ship a stale summary. The symptom of getting this wrong is a row that is
only right after reopening Settings.
The boot trigger publishes the snapshot itself after awaiting the refresh.
The boot snapshot is spawned separately and can serialize first, which left
the row absent for a user who already had the toggle on until some
unrelated mutation happened to republish.
A cap change drops the tier-keyed audio cache, symmetric with the streaming
-quality change: the cache is quality-blind, so bytes fetched under the old
tier would keep serving and the feature would be inert on anything already
played. The first refresh of a process is exempt -- see the CapChange
classification, which exists because clearing there wiped the disk cache on
every launch.
cc
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
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
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
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
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
Block B6 of the miniplayer/tray contract. B5 landed a tray whose clicks
reached Rust and stopped there; this is the other half.
qml/Main.qml — hideToTray(), showFromTray() and the ONE close choreography
every exit now calls, plus the handlers for the tray bridge's four signals
and the mini's close-app signal. The close predicate is both conditions,
trayLive && closeToTray: with the tray off a close QUITS, because gating on
the setting alone strands the process with no window on a desktop that has no
SNI host. close.accepted = false happens before the hide — hiding never arms
Qt's quit check, an accepted close does.
The miniplayer's enter and exit are rewired onto those two verbs, which is
what makes the tray toggle honest: the reference hides the main window behind
the tray's back and its second tray click brings the main window up beside a
live mini.
THE EXITS ARE FIVE, NOT FOUR. The contract enumerated four and gave the grep
that finds them; re-run today it returns a fifth, because the kiosk shell
grew its own drawn close button after the contract was written. It goes
through the same choreography. Four POC-NOTEs die here.
showFromTray also un-iconifies. Qt's hide() preserves windowStates where the
reference destroys the surface, so a window minimized before it was hidden
would otherwise come back minimized — present in the taskbar, invisible on
screen, which reads as the tray having done nothing.
qml/settings/AppearanceSettings.qml + src/settings_qt.rs — the three macOS
rows: the MENU BAR header swap, the macOS wording on Close-to-tray, and the
Hide-Dock row, all absent on Linux. minimize_to_tray gets storage and no row,
which is the owner's standing decision. And the tray-close-to-tray write arm
now pushes to the bridge beside its persist, so toggling it changes the very
next close with no restart.
src/mini_bridge.rs — close_app and the signal it routes through, so the
mini's red X will honour close-to-tray like every other exit. Its button is
B3's, so the chain has no caller yet.
Driven, not assumed: with the tray live and close-to-tray just pushed false,
the app-menu Close row QUIT the process — which can only happen if the push
reached the bridge, since the seed had set it true. And Shift+M then Escape
returns the main window pixel-identical, zero of 1.6 M pixels changed, with
the mini alone on a black framebuffer in between: the main window really is
hidden, and Qt's hide/show preserves its geometry with no restore call.
cc
Full-parity port of the Slint QConnect integration per the contract at
qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three
adversarial review rounds on the spec, §12 trap sign-off PASS on both
halves, RFB-verified incl. a real cloud connect, live session
owner-tested). The shared crates are consumed verbatim — zero backend
changes.
- adapter quartet: qconnect_transport_qt (credentials, identity on the
shared qconnect_settings.db), qconnect_engine_qt (start_track_stream
fallback chain + feeder helpers + CMAF cursor sync),
qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect
recipe, controller routing, echo guards, offline parking B12, startup
auto-connect)
- QbzQConnect bridge singleton + on_session_entered wiring; one shared
flyout for both bars (tooltip on the large bar only), Developer >
Connect diagnostics modal, device-row glyphs, settings rows live
- poll loop: peer-reflection branch before cast, ~2 s renderer reports,
sync-on-add on every Qobuz-castable add path
- routing: cast → QConnect → local (mute QConnect-first), play arms
after the set_queue funnel, reorder cloud arm with no local fallback,
volume/mute/seek remote-first
- touchpoints: lyrics remote anchor, scrobble guarded / Discord
unguarded, cast suspend/resume, volume-lock lifted when remote
km
The port had a 67-line settings row showing two counters and a header saying
the Manage chevron was "NOT shipped — this port has no Blacklist Manager view
for it to open". The Slint has 1,114 lines of manager plus the consumer surfaces
that make the feature mean anything.
- src/artist_blacklist.rs is a 1:1 port of the reference's per-user lifecycle,
including the fail-open asymmetry (is_enabled reads TRUE when there is no
session, everything else false/empty) and the byte-identical NO_SESSION_ERR.
src/reco_dismiss_qt.rs carries the reco-dismissal store. Both are bound in
ALL THREE session-activation paths — the offline one included, because the
reference records that Tauri never bound it offline and blacklisted artists
leaked into offline surfaces — and torn down on logout, or the next account
inherits them.
- The manager view: three tabs (bordered, ADR-008 — no pills), search, sort,
the enable toggle, the disabled warning banner, the four body branches per
tab in their load-bearing order, windowed lists, and the clear-all confirm.
Reached from Settings only, exactly like the Slint; no sidebar entry.
- Every one of the ten store mutations publishes TWICE — its own document and
publish_settings() — because the Settings counters live in a different
document and nav_qt::back() re-runs no per-view load. Without it,
Manage -> unblock -> Back showed pre-mutation counters. The Slint gets this
free from a shared global.
- The search field is driven imperatively, not by a binding: QbzLineEdit's X
used to destroy the binding, after which the box went blank while the list
stayed filtered.
- Consumer surfaces, which is where the feature actually lives: the album card
and album list row gained "Block this album", the artist card gained the
3-arg "Not interested", the album header menu gained Block/Unblock swapping
on state, and the artist page gained the Show/Blacklist flip plus the amber
hidden banner with its "Show artist" button. album_qt and artist_qt now seed
isAlbumBlocked / isBlacklisted at document build time — without the seed an
already-blocked album offered "Block this album" and the first click
UN-blocked it, the same lie the pin glyph told before isPinned existed.
- search_qt passed &Default::default() for both blacklist snapshots, so nothing
was filtered anywhere and a greyed row still played. It now passes the real
snapshots at both call sites.
- The 17 toasts the spec had dropped for want of a toast host are wired: that
premise stopped being true when toast_qt landed, and a Clear All that failed
with NO_SESSION_ERR was completely silent. Their msgids are in all eight
catalogues; the five album-axis ones existed only in es and nl.
Also fixed in passing, pre-existing on this branch and unrelated to the
blacklist: ArtistView's library tab read `libTracks` unqualified from a child
of the object that declares it, which QML resolves against the component root,
not the enclosing object — so the "Tracks" heading threw a ReferenceError and
never rendered. Found by forcing the artist route in the offscreen smoke, which
no earlier gate did.
cc
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light
themes secondary, muted and accent were simply wrong, and accent was wrong on
any theme whose accent is not #4285f4. Slint recolours at render time, which
this path cannot do — so the tint is generated once per colour into the cache
and loaded through the same Image+svg path the bakes already use. No effect, no
shader, so it cannot hit the "renders nothing on software" failure.
The sweep found the part the diagnosis had missed: "primary" was two meanings
welded together — max-contrast-on-theme-surface, and literal white for glyphs on
hosts that are dark under every theme. Remapping it globally would have painted
dark glyphs on dark scrims in fourteen places. The vocabulary is split instead,
with "primary" left as an alias so nothing changes until it is migrated
deliberately.
**The album header gradient was Slint's fallback, not its design.** Slint paints
a blurred artwork field and only falls back to the flat two-band gradient when
that image is unavailable; the port shipped the fallback. The field needs no
shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down
to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look,
not an optimization; everything after it works from eight pixels of colour,
which is why it reads as atmosphere rather than as a blurred cover. It comes out
of the same decode that already produced the flat tint.
**Covers were point-sampled.** Canvas drawImage does not filter when it scales,
measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in
a 200px cell throws away eight of every nine pixels, and the error is aliasing,
not softness. Rust now produces a derivative at the drawn device size so the
draw is 1:1 and there is no resample at all.
**The now-playing context stack.** It had no Qt counterpart: the model carried
context_kind and context_id, some builders stamped them, and nothing ever read
them back. The album path only appeared to work by coincidence, because the
glyph was hardcoded to open the album.
Plus: Discover's missing rails with real play history, Recommendations on the
integrations, queue drag reorder, and the playlist detail collage.
cc
The pane was written fast and had drifted both ways: four whole sections were
a placeholder reading "not ported to the Qt frontend yet", and what was there
carried rows and copy Slint does not have — an invented custom-theme info row,
made-up "Restart to apply" descriptions, dividers Integrations never had, and
two buttons forked inline instead of using the shared control.
Every section is real now, in Slint's order and grouping, with Slint's labels
and control types: Audio, Playback, Appearance, Offline, Local Library,
Blacklist, Integrations, Developer, and the sandbox section that only appears
on a Flatpak or Snap install. Local Library brings the folder table and the
Plex panel — 856 lines of Slint with no counterpart here until now — wired to
the Plex store that landed earlier this round. Its PIN auth flow is not ported,
so the manual server-url and token path is what this exposes.
The rule the round was run under: a row whose key is not in settings_qt's
dispatch renders and does nothing, so it is either wired or not shipped.
"Share logs" was exactly that — present since the port began, inert — and now
opens the on-disk log.
The directory is per-section files under a thin composition root rather than
one growing file, and the sub-nav's bottom spacer stops being arithmetic over a
hardcoded row count, which broke the moment anyone added a section.
cc
The Integrations pane rendered rows that did nothing: Last.fm, ListenBrainz,
MusicBrainz and Discord all had prefs but no runtime behind them, and Discord
never pushed a presence at all.
Each row now drives the qbz-integrations APIs for real — connect/disconnect
state, the account label once connected, and the per-service toggles — and the
runtime starts on shell entry, applying the persisted opt-ins and the scrobble
queue's flush watcher. Scrobbles arm on the de-duped track edge (firing from
the republish path would re-arm the timer on every poll tick) and the Discord
presence refreshes on both the track and the play/pause edges, then clears on
logout so a signed-out session stops advertising what was playing.
All of it stays strictly opt-in: nothing here does anything until the user
connects the service.
cc
Two fronts of the modularization, both gated green before this commit.
QML leaves the flat qml/ directory for seven modules: cards, controls,
rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml
stay at the root. build.rs matches the tree exactly (55 files), relative
imports and asset paths were re-anchored for the new depth.
The QbzBridge God-object starts breaking up: QbzSession, QbzShell,
QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge]
singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted
in order from Main.qml. Bridge files stay directly under src/ because
cxx-qt-build only accepts rust_files from one directory (QTBUG-93443).
bridge.rs keeps 5.5 domains (library, album/artist, settings,
integrations, search, playlist) and dies once they are split out.
cc