The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
The reported symptom was titles eliding early in every bar mode. Root
cause was not the breakpoints: Main.qml pinned 1280x800 on every launch
while Slint restores window_width from the shared ui_prefs.json (1716 on
the owner's profile). 1280 sits one tier below the 1366 breakpoint, so
Classic's song card capped at 446px instead of reaching its 560px cap --
the ~114px of early elision.
Restoring alone would have fixed that. Saving is the half that makes the
feature real, and it is where the risk lives, so it is built to Slint's
semantics rather than invented: floating-size-only keys, the app minimum
as a gate, the >0.5px dirty check, maximized only ever re-applied.
Two things Qt makes harder than winit and both bit:
Visibility is ONE enum where Minimized replaces Maximized, while winit
keeps them orthogonal and still reports maximized while iconified. So
the flag is latched from the last non-transient visibility. The first
attempt read that through a derived property inside the handler for the
same signal that dirties it, which is one transition stale -- it passed
grep, qmllint and rustfmt, and was caught only by driving real
transitions under qml6. It is a function now; a function has no cached
value to be stale.
And the writes share a file with the shipping Slint app. A truncating
fs::write plus a json!({}) fallback on parse failure meant either side
could flatten the other's document: Slint reading inside the truncate
window falls back to UiPrefs::default() and writes those defaults back.
Every writer in the crate now goes through one atomic read-modify-write
(tmp + rename), and a file that is present but unparsable aborts the
save instead of being rebuilt from nothing.
The Slint side keeps its own non-atomic write, so the cross-process race
is narrowed, not closed.
cc
The two cast buttons in the now-playing bars rendered and did nothing: no
service, no bridge, no picker. This ports crates/qbz/src/cast_service.rs
onto the shared qbz-cast crate (discovery, both device families and the
media server are the crate's, not reimplemented) and hangs a QbzCast
singleton off it.
Discovery is armed only by openPicker() and stopped by closePicker(), so a
user who never opens the picker sends no mDNS/SSDP traffic. Connecting
stops the local backend through its public seam and re-casts the current
track at the streaming preference clamped by the per-renderer cap.
Playback is routed at four seams in playback_qt (new track, toggle, seek,
volume) and the local poll yields while casting — without that the two
polls fight over the bar every second. Logout and exit tear the connection
down, or the renderer keeps playing after the app is gone.
Deliberate gaps, each named in the module header: no offline-cache tier,
no QConnect suspend/restore, no Plex casting (the Slint carries the same
TODO), and lyrics do not follow a remote position yet.
cc
**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.
**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.
**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.
**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.
**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.
**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.
Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.
The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.
cc
Two singletons rather than one merged object, per the owner: each maps to the
view it backs. Registering them and deleting the duplicates from bridge.rs is
one change, because wiring one without removing the other compiles fine and
leaves two independent states with the view frozen and nothing in the log.
view_param_id goes with them. It had no consumer in QML, verified, and none in
Rust once the two opens stopped writing it — so it is deleted rather than
carried into a new bridge for symmetry's sake.
bridge.rs is down to three domains: settings, search and playlist.
cc
library_bridge.rs has sat in the tree unreferenced since the phase-23 work
stopped: no mod declaration, not in build.rs, zero call sites, and duplicating
members that were still live in bridge.rs. It could not have been wired as it
stood — its module was named qbz_library, which is ambiguous with the
qbz-library crate this binary depends on, so it never compiled. The module is
now qbz_library_bridge; the QML type name comes from the QObject, so QML is
unaffected.
Registering it and deleting the duplicates had to happen together. Wiring it
alone would have compiled cleanly and produced two independent Library states —
QML reading one object while Rust published to the other — with the view simply
frozen and nothing in the log.
Moved with it: four properties, five invokables, three signals, the seven
library-only ui() hops in main.rs, and thirty-four QML references. The
Connections blocks in LibraryView, Sidebar, QueuePanel, AlbumView and
PlaylistCollage retarget wholesale because every handler in them is a library
signal. ArtistView's does not: it mixes onLibraryArtworkReady with
onReleaseSectionReady, so it becomes two blocks — retargeting it whole would
have orphaned the releases pager, and QML resolves handlers lazily enough that
the discography would just have stopped loading with nothing in the log.
bridge.rs is down to 389 lines and five domains.
cc
The sidebar has had a "Local Library" row since the shell was ported and
nothing behind it: the nav entry did not even navigate. This builds the view.
QbzLocal is its own domain bridge (the phase-23 pattern, one #[cxx_qt::bridge]
per domain) over local_library_qt, which consumes qbz-library for the scanning,
the folder-vs-tag album identity and the queries rather than reimplementing any
of it. The view carries the folder tree, the album grid, album detail, the
track tabs, sorting and search, plus the scanning and empty states.
Windowed from the start, not as a later pass: real local libraries reach 16k
tracks and that is exactly the surface where the Slint port freezes. Artwork
is id-keyed and evicted with the window.
Playback is source-aware — a local track plays from disk through the player's
play_data seam, because the Qobuz tier-walk needs a client and fails on every
local advance. TrackRow gains an opt-out for its drag source: a local row's id
is a local database row, and the sidebar's drop handler forwards whatever it
receives to `playlist add-tracks` as a Qobuz catalog id, so dragging one onto a
playlist would have silently added an unrelated track.
Not included, and tracked as follow-ups: Plex union and thumbnails, multi-select
and the bulk bars, ephemeral folders, the tag editor, A-Z jump strips, the
group-by dropdown, the tree's resize handle, per-tab scroll restore, and local
favourites on rows. Album detail renders inside the view rather than as its own
routed page.
cc
The port had reinvented this badge: an icon-only replica with no MP3 tier, no
full or compact form, and none of the playback states. QualityBadge.svelte and
QualityBadgeStatic.svelte in the archived Tauri tree are the reference, and
every number here comes from their CSS.
Four modes, the union of both originals: full (the 136x36 pill with tier row
and bit-rate), compact (the narrow-bar "24/96" form from issue #303), bare
(full's content without the pill chrome, for toolbars) and iconOnly (the
Hi-Res brand mark alone, or a framed 30px square for CD/MP3 so both line up in
a grid). Tier resolution follows the original's order, MP3 first so it can
never come out as CD.
The four playback states come with it: system resampling, ALSA plughw
fallback, deliberate quality adjustment and confirmed bit-perfect, each with
its own border hue and indicator glyph, plus the rich hover tooltip carrying
source, output, the reason and the fix. Rows that only advertise stored
quality leave the flags false, which is exactly what QualityBadgeStatic was.
QualityMini becomes an alias over iconOnly mode rather than a second drawing
of the same thing, so the six existing call sites keep working while there is
one badge again.
Brings the assets the port was missing: cd.svg, mp3.svg and the LINE Seed JP
face the badge is set in. Their tint is baked to #b3b3b3 because Tauri applied
it with a CSS filter and QML has none on the software path. Adds theme.alphaTier(pct)
so the badge names the alpha ramp the design specifies instead of a raw index.
build.rs now collects the whole asset tree recursively — the hand-written root
list meant a new asset compiled clean and failed at runtime with "Cannot open".
18/18 tests; offscreen smoke clean.
cc
The Large dock was a bare cover overlay: the sidebar knew nothing about it,
so the playlist rows kept running to the sidebar's bottom edge and sat
underneath the album art. Sidebar.slint reserves the in-sidebar portion of
the dock; the port had dropped that.
Reserve it the same way, from a single source of truth. QbzShell gains
largeDockHeight (shell_bridge.rs owns the arithmetic: 9 top + band + gap +
208 art + 4 bottom), the sidebar subtracts largeDockHeight - npbLargeHeight
from the playlist list, and AppShell pins the overlay at height -
largeDockHeight. The 112px bar height becomes a theme token so the shell and
the reservation cannot drift apart.
The band the cover was missing needs the FFT tap, so wire it: the runtime is
built with_visualizer, viz_qt.rs runs the frontend-agnostic producer from
qbz-audio and drains it at 30fps into a new QbzViz domain bridge as typed
QList<f32>. Bars (28 mirrored columns over the 14 active bins), Waveform (48
downsampled L columns) and Energy (5 bands) render as Slint does, the cover's
eye button toggles the band, clicking it cycles the mode, and both persist to
ui_prefs.
Cost is zero when unused: the tap starts disabled, the eye toggle gates
capture, the drain thread parks while off, and the transport is mirrored so a
paused player parks the producer instead of re-analyzing a stale buffer. The
QML binds bar HEIGHTS against static-model Repeaters — the lists are replaced
per frame, so a model binding would rebuild every delegate 30 times a second.
Adds the eye/eye-off icon pair to the seven baked tint variants.
18/18 tests; offscreen smoke clean (0 QML errors, home published).
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
Port of the spec's palette derivation (16x16 downscale, coverage-
weighted 24-bin hue histogram, genuine secondary cluster or deeper
same-hue, +180 accent, B/W defaults) driven by the now-playing artwork
on track change. QPainter AmbientField with the WGSL scene's exact
four blob orbits, dim scrim, 30fps cap paused when inactive; chrome
surfaces translucent per the AppShell alpha tokens (content frame
surface-main @ 0.22 frosted). Same shared app_background pref (owner
has it 'ambient'), app-menu toggle applying live, QBZ_BG_* env knobs.
4 new unit tests (14/14 total).
km
Library audit: the phase-7 conversion never actually mounted the shared
AlbumCard — ALL kinds used the invented feed card. Now per-kind 1:1:
LibTrackCard (discover/TrackCard replica with wired next/later/queue
enqueue), shared AlbumCard for albums, LibArtistCard (ArtistGridCard
circle), LibPlaylistCard (collage, follow-aware), LibLabelCard
(gradient + contain-fit logo), list rows rewritten to the real
FavoritesView inline row, Tracks tab on a TrackRow replica. Discover:
Editor's Picks and For You tabs rendered from the same discover-index
fetch, prefs-ordered per DiscoverPrefsStore (incl. mostStreamed#album
variant, Qobuz Mixes gradient tiles, Pinned in the user's own position);
Recommendations tab gated on the user's pref with an explicit engine
placeholder. No refetch on tab switches.
km
Rounding: QML clip is rectangular (radius+clip does NOT round-clip) —
new shared RoundedImage (software Canvas raster clip, GL-independent)
applied at 16 artwork sites incl. true circular artist crops. Menus:
new QbzContextMenu (cursor-anchored / below-trigger, window-clamped)
converted at 7 sites, replicating TrackRow.slint positioning. Window:
main window is OPAQUE in Slint (transparency is miniplayer-only;
rounded corners are KWin's) — dropped the translucent frameless
misread, squared app corners, opaque root. Titlebar: wired the EXISTING
shared ui_prefs use_system_title_bar key (app-menu checkable item,
restart semantics like Slint).
km
One AlbumCard.qml replicating the Slint shared card (pin badge with
PinnedItemsService wired, heart, hover play overlay, exact context
menu with Play next/Add to queue wired) used by BOTH Home rails and
the Library All grid. Frameless translucent window with 12px rounded
corners, replicated WindowControls, header drag, 8 resize grips.
Sidebar playlist tree: 56 playlists / 12 folders live, expand state,
5-option sort with direction caret, recursive search filter, collage
covers, create-playlist wired. Fixed a std-Mutex self-deadlock in the
sidebar loader (two lock temporaries in one log statement).
km
HeaderBar (42px, sidebar cycle, back/forward, centered search replica,
offline badge + flyout, app menu), three-state Sidebar (open 240 / mini
64 / closed 0, 160ms animation, nav rows with exact msgids), NowPlaying
bar SMALL (3px three-color seekbar, art+title+time left, transport
center, quality/volume/queue-toggle right, exact empty state), Queue
panel (300px animated column, Queue/History tabs, exact empty states).
Nav history stack + NowPlayingModel with empty-state defaults behind
new bridge properties/invokables (transport log-and-noop until
playback lands). Icons as pre-baked tinted SVG variants (ColorOverlay
is broken on the software path); Inter fonts bundled; full dark theme
tokens ported from the Slint foundation files.
km
QbzBridge QML singleton (12 properties, 8 invokables) driven by tokio
tasks via CxxQtThread. Port of auth.rs (browser OAuth loopback listener
with qbzd-style nonce, 180s timeout, cancel via JoinHandle abort,
scoped offline-gate lift for cold bundle init, D1 token-keep on network
failure / clear on explicit rejection, D4 subscription grace marks).
OfflineModeEngine + ConnectivityActor started at boot with live watch
forwarding (connectivity/captive portal/recovery banner/has-previous-
session). Boot: splash -> silent token restore -> shell | login.
Start-offline enters the guest profile. LoginScreen.qml is a faithful
port with exact msgids routed through qbz-i18n. 5 unit tests green.
km
New qbz-qt bin crate (cxx-qt 0.7 + QML) as the only default workspace
member in this worktree, so plain cargo build produces just the Qt
binary. Gate verified against system Qt 6.11.1 (codegen, moc,
qmltyperegistrar, qrc load, offscreen run all green).
km