StyledText underlines every `<a>` anchor unconditionally and nothing
else in the app underlines a link. The names are now colour spans in
the same StyledText (elision kept), and the click and hover hit test
measures the prefix up to each name with the row's own font, so each
name still opens its page or its search on its own.
cc
CardMenu entries declare a submenu as data (`submenu: [ {label, icon,
action} ]`); the row builds a child CardMenu on first use, opens it when
the pointer rests on the entry (or clicks it), and closes it when the
pointer moves to another entry. While either menu is hovered the pair
stays open; leaving both for 350 ms, pressing Escape or clicking outside
closes both. Picking in the child closes the parent too.
The track row, the Popular Tracks row and the player bar's "+" menu
declare their Copy entries this way instead of re-opening a second
CardMenu at the row's saved click position.
cc
Unavailable tracks (8): playlist rows now say when the WHOLE release is
gone (`releaseUnavailable`, one probe per distinct album of the pulled rows
through the library's helper); TrackRow's album link and "Go to album" go
dark on it instead of opening an empty page. Every row that opens an album
passes the names it knows (`openAlbumFrom`), so when the catalog answers
404 the page names the release: AlbumView renders an "unavailable" arm —
heading, "This release is no longer available on Qobuz." — with the
best-ranked alternatives Qobuz still sells (the replacement flow's ranking,
exposed as `ranked_alternatives`) in a rail; a network failure gets the
same page with "Couldn't load this release." and no rail.
Copy (9): the player bar's "+" and the track row menus (shared TrackRow
and the artist's Popular Tracks rows) gain a Copy entry with a submenu —
"Track name" or "Track - Album - Artist" — through the shared clipboard
carrier; CardMenu marks `submenu: true` entries with a chevron and the
host reopens the second menu on the same anchor. Row menus keep both lazy.
Buy on Qobuz (10): the album header shows a store action when the catalog
says the release is purchasable and it is not already owned; the track
menus and the player bar carry the same entry for catalog rows and resolve
it at click time — the store page opens in the browser, or a toast says
the release is not sold. The link is `https://www.qobuz.com/album/-/{id}`,
which the store answers with a 301 to the localised page (verified).
cc
With featured performers shown, the line is one elided StyledText whose
names are separate links: the main artist opens its page as before; a
featured name opens its page when the album credits carry its id and the
search for that name otherwise. Rows send the names with ids resolved.
cc
Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.
Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.
Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.
cx
Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus
22 shared opt-in hosts, shared navigation history with Back/Forward, Albums
as the default library tab, native paged Local Library (artists/tracks),
lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and
artwork sized to the physical frame, static skeletons, 64px transport bar,
Settings touch reflow and a persistent Immersive exit.
Desktop keeps its prior behavior: every shared component defaults to the
desktop path and only opts into Kiosk behavior through explicit properties
(origin-relative ScrollMemory, viewport-bounded artwork requests).
Regression coverage: kiosk navigation and artwork Node tests added to the
runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n
strings for all eight locales.
Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and
1280x800 and a Desktop A/B with seven pixel-identical captures.
cc
/album/get sends streamable_at and release_date_stream per track: a future
value marks a pre-release, null a pulled track. Track carries both fields
and Track::is_upcoming applies the one rule (future streamable_at wins,
then a future release date); the album, playlist, search and label rows
publish qobuzUpcoming and the unavailable glyph reads "This track is not
yet available" instead of "no longer". Artist page rows carry no dates and
keep the generic copy. New msgid in the seven translated catalogs.
cc
The shared drag gains a LOCAL payload arm: a Local Library / Explorer
row enters via dragStartLocal carrying its ROW ID, resolved to
source-aware refs at drop time — a library row id can never be read as
a catalog id, the same invariant the picker's Payload enum enforces.
TrackRow's localDragSource reuses every existing guardrail (horizontal
arming, select-mode/dead-row/playBlocked gates, preventStealing,
cancel teardown) without touching catalogRow's meaning. Drops route
through the picker's own add matrix: refs onto a local playlist or the
Qobuz sidecar, and Qobuz ids onto a local playlist (previously a
documented POC gap); queue drops land at the exact slot through the
same insert path as Qobuz drags. Native-catalog tracks rows keep the
gesture off until their identity can ride it.
cc
A double-click on a clickPlays row fired playRequested three times
(two releases plus doubleClicked) and restarted the track three times
within 230 ms. Debounce the release and skip the doubleClicked arm on
clickPlays rows.
cc
Preserve per-disc and collection artwork, multi-value genres and remote metadata across Local, Plex, Jellyfin and Subsonic.
Apply source, format and quality filters before pagination, reject invalid CUE rows, and keep Qt library surfaces aligned with the selected physical version.
cx
Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.
NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.
The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.
Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.
Three judgement calls, stated because they are not forced:
- Library gets TWO models, one per kind. Its rows are mixed, and one
anchor would let a Shift-click on a track sweep up the albums drawn
between it and the anchor.
- Ranges run over the VISIBLE rows. Local Library's search box is a
view filter, and a range over rows the user cannot see is not the
range they asked for — the same call `select-all` already made.
- The Local Library tree rail keeps plain toggles. Its selection lives
in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
an anchor off; teaching the Rust side about an anchor is its own
change, and the file says so where a reader would expect otherwise.
The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.
Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.
cc
can be read
RIP WIZARD, three steps, each answering something the first version got wrong
by not asking. What will happen — which disc, how many tracks, and that the
output is FLAC, stated rather than implied. The tracks, editable, because a
CD-DA carries no titles at all so "named wrong" is the ordinary case and once
files are on disk it is a rename job. And what happens afterwards: a ripped
album the library cannot see is a folder full of files, so the destination is
compared against the registered folders — by path COMPONENTS, not by string
prefix, since /music-backup starts with /music and is not inside it — and the
right follow-up is offered, naming the folder it would re-scan. "Do nothing" is
an explicit choice rather than the silent default. The follow-up runs only on
success.
Tracks can be deselected. Everything is ticked on arrival and the user goes
from all to fewer; a wizard that starts empty makes the common case the one
that costs the most clicks. On the Rust side an absent `selected` means yes, so
an older form cannot silently rip nothing.
A PROGRESS PANEL behind the ripping chip, which is a door rather than a label:
it is the only thing on screen during a rip and the two questions it could not
answer are the ones that matter. A soft lock says not to eject — soft because
nothing here can hold a tray shut, so the honest move is to say it and keep
saying it. Per-track state with a bar on the row that is actually moving.
Stopping is offered, deletes nothing, and says so; a cancelled job does not
jump to 100 %, which is the one lie a progress bar must not tell.
CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder,
which has no identity to write a correction under. The list shows what
distinguishes four pressings of one album rather than four identical titles,
and flags a track count that disagrees with the disc. Selecting is not
applying: the candidate is fetched in full and its track list shown, because
that list is the only thing proving a plausible title is the right pressing.
Applying renames the live session without renumbering ids — a re-open would
orphan a playing track — and writes a user row. Only MusicBrainz re-resolves
the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is
worse than the old one. The correction can be dropped, because without an
escape a bad apply is permanent and that makes people afraid of the button.
THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track
set whose album_group_key is the box root for every file while the disc number
sits in the data unused — so the pane was a flat list 247 rows long. The key
carries the disc, unconditionally, because it round-trips through the play-album
lookup. Its own client-side filter over what is already rendered, since finding
one track meant scrolling past the other 246.
EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published
isEphemeral on every row for a while and the queue panel respected it; the bar
never read it. The heart goes inert, the add menu keeps only its queue actions,
and the row menu comes back with those three and nothing else. Absent rather
than rendered-and-inert.
Also: a spinner on Open that blocks a second click onto a drive already being
read, held by a counting RAII guard because opening is a relay and the chip
must not blink off in the seam; the now-playing card routes a disc to the pane
it came from instead of to an album id no catalogue can open; the file pickers
use rfd, the platform's own dialog, which the POC-NOTE said was a five-line
change once the dependency existed — it existed, Settings had been calling it
from this crate for weeks, and on macOS the four Linux choosers do not exist so
Open folder and Open SACD image opened nothing at all, in silence; the empty
state stands down for the Open pane, which on a machine with no library drew
both fully painted; tooltips on every disc control through one hover() helper,
since two call sites had already mismatched their key, which does not fail —
the bubble simply never goes away.
cc
The now-playing affordance — the accent pill on the row's left edge and the
eq bars in the number cell — never appeared for an offline row on the very
view it was played from. `TrackRow` asked one question,
QbzPlayer.npTrackId === item.id
and for a `qobuz_download` row the two sides come from different id spaces:
the queue id is the QOBUZ CATALOG id (the offline tier is keyed on it) while
every Local Library list draws the row with its `library.db` id. They never
matched, so the row sat dark while its own audio played.
The reference has a second published id for exactly this and the port never
carried it over: `NowPlayingState.local-track-id` (playback.rs:1978-1986).
This adds its twin, `QbzPlayer.npLocalTrackId`, and makes the row match
either.
Published for Offline rows ONLY, and that restriction is the safety of it:
`source_item_id_hint` is a shared slot carrying a Plex ALBUM key and a media
server's own item id for those sources, so publishing it unconditionally
would hand a list a number from another id space and light the wrong row.
It stays empty elsewhere, which makes the second test inert there.
Scope checked by driving the window rather than reasoned about: the pill and
the play glyph land on the right row for local user files, Plex, Jellyfin and
Subsonic both before and after this change — offline was the only source
whose id diverged. The other three surfaces that ask the same question need
no alias: the queue panel and the kiosk album carry queue ids, and the
purchases list carries catalog ids.
qbz-qt 431/431, 4 QML audits green.
cc
Three places still spelled "not a Qobuz row" as "local or plex", which
made a Jellyfin or Subsonic row fall into the catalog class:
- Library ALL feed stamped the album source from the ID PREFIX
(`plex:`), so every media-server album came out as "local" and drew
the local hard-drive badge. Reads the row's own `source` word now —
byte-identical for local and Plex, correct for the rest. The same
failure `local_rows::badge_source` had.
- TrackRow's `localSourceRow` gated "Add to playlist" and the offline
download entry. A Jellyfin row counted as a catalog row, so the menu
offered to send a namespaced synthetic id to the Qobuz playlist API,
which adds an UNRELATED track. An allowlist, not `!== "qobuz"`:
"offline" is an offline-CACHED Qobuz row whose id IS a catalog id.
- AlbumCard hid "Block this album" on local/plex only, so a server album
offered to blacklist an id the Qobuz catalog never had.
`feed_track_to_queue` is taught the words too. Media-server TRACKS do
not ride that feed today (only their albums do), so that one is the
guard learning the vocabulary before it is needed rather than a live
fix — its existing test, which pins that a local id must never resolve
as the Qobuz track carrying the same number, now covers all of them.
cc
DID NOT DELIVER ITS GOAL. Read this before extending it: the wheel scroll on
a 189-track album is essentially unchanged. The page is correct and the
structure is better, but the theory that motivated the work is not supported.
What the work assumed: the ceiling was the `Repeater`, which has no windowing
and so built all 189 track blocks — each a Column with a disc-header Loader, a
work-header Loader (both gated on DATA, never on the viewport, so every act and
scene header in a Wagner box was alive) and the band-gated row. Roughly a
thousand structural items before counting anything visible.
What it is now: one ListView over a UNIFORM 10px cell tape. Each piece of
content owns a run of cells, its visual item lives in the first cell of the run
and paints over the inert ones — 50px row = 5 cells, 40px header = 4, rail =
29. Uniform cells are the point: measured in a standalone probe, a ListView
with variable delegates ESTIMATES contentHeight and ignores an explicit
override (5703 then 5396 against an exact 5400), which makes the scrollbar
thumb resize and skip as sizes are discovered. With uniform cells,
count * 10 is exact from the first frame, so the scrollbar the owner had just
called the best gesture on the page keeps its behaviour.
Falling out of the design, replacing machinery that was reverted a commit ago:
`cacheBuffer` incubates upcoming delegates asynchronously in idle time, so a
heavy rail is built before it is visible without a hand-rolled band, a settle
Timer, or an unload grace — and without the "reach the bottom, then wait" that
made the owner reject that approach.
Two correctness items that came with it:
* `header` is a ListView property. The page's own `header` had to become
`albumHeader` or every `header.x` binding inside the view would silently
resolve against the view's header instead. One handler was missed in the
rename and QML refused to create the component at all — the whole page
rendered black, and no static gate caught it because the failure happens at
instantiation.
* `recycleActive` on the shared TrackRow, default true, gates the row's
Connections. Without it a pooled row keeps listening and consumes a cache
or favourite settle signal meant for a different track. Every other host
is unaffected by the default.
WHAT THIS RESULT MEANS, for whoever picks it up: delegate count and
construction cost are NOT what makes this page feel heavy. Removing ~950
structural items changed nothing the owner could feel, which points at
per-frame cost instead — consistent with his own observation that the page is
sluggish ALWAYS, not only while it loads, and with the documented finding that
this shell's bill is full-window presents rather than scene work. Do not
re-attempt a structural rewrite here on intuition. Take the QSG_RENDER_TIMING
split (polish/sync vs render) on this exact album first; the earlier reference
scene measured polish=0ms, render=2ms, swap=12-24ms.
Design and probes came from an independent review; the rebase onto the current
tree, the black-screen fix and the API verification are mine.
cc
Follow-up to 66ba12bd5, which fixed the coverflow but left two of its
neighbours and a gate that could not see either of them.
QueueTabsPanel built one ImmQueueRow per queue track when opened —
including every collapsed 0-height history slot, which on the reported
session was 1162 of 1935. The slot stays (its height is what makes the
collapsed-history arithmetic work); the row inside is now built only
within a viewport of the visible band.
SuggestionsPanel and AppShell's QueuePanel move behind Loaders. The
queue panel was never the freeze — its rows are paginated at 40 in
Rust — but a closed panel should not be constructed, and the audit
cannot know about a cap that lives on the other side of the bridge.
Gating it also let SuggestionsPanel drop the onVisibleChanged half of
its load hook: mounting IS becoming visible now.
The audit is the real content of this commit. Written narrow — only a
model fed by unbounded user data counts, because the wide rule reports
145 sites and a gate that noisy gets switched off in a day — it then
reported the pre-fix tree CLEAN. A gate that cannot catch the bug it
was written for is worth nothing, so it was tested against that tree
and fixed on two counts:
- CROSS-FILE. The `visible:` sat in ImmersiveView.qml while the two
Repeaters over the whole queue lived in CoverflowPanel.qml. There
is now a transitive index: a component is heavy if its own file has
an unbounded view or if it mounts something that does.
- MULTI-LINE. The coverflow's gate spans two lines, so a first-line
read saw `QbzShaderScene.scene === 0`, matched no known gate, and
skipped it.
With both, it finds all five mounts on the pre-fix tree, including the
two that froze the app. Known gates also grew past tab predicates: the
coverflow was keyed on QbzImmersive.mode.
Verified on a copy of the real session state with the 1935-row queue
restored: launches to a painted Discover, idle at 1.0% CPU.
cc
Smoke found the row rendering correctly and the menu still offering four
entries that cannot work on it.
Add to mixtape and Add to playlist are refused, in both row implementations.
The precedent was already there -- `hasPlaylistAddSeam` excluded a broken local
ref for the same reason -- this is the same idea with a different cause. Gated
on `pulled` rather than `pulledDead`, so a downloaded copy is refused too:
what a playlist or mixtape stores is the Qobuz catalog id, which is dead on
every other device, every other surface and for anyone the list is shared with.
The local copy playing here does not make the row safe to seed elsewhere.
Both share links go too. Qobuz's page for a withdrawn track is gone and
Song.link resolves through it, so each one rendered and led nowhere. Track info
as well: it needs the catalog record the pull took away.
That empties the menu on a surface that does not offer the repair -- an album
page, search results -- so the row menu no longer opens when its model comes
back empty. An empty popup reads as a broken control rather than as "there is
nothing to do with this row". The ArtistView fork keeps its go-to entries and
can never reach that state.
cc
Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier,
sometimes it republishes the same recording under a new album id. The row keeps
appearing and simply cannot play. Until now the user found out by clicking it.
The app already knew and threw the answer away: eight builders hardcoded
`streamable: true`, so the API's own flag never reached a row model. The
reactive half was already here and stays -- a terminal failure still toasts and
skips, bounded at five consecutive, with the 403 back-off kept separate so a
403 storm cannot burn five good tracks.
Detection has two clauses. The API's `streamable: false`, and a session-only
memory of tracks that died terminally under the player this run: /album/get is
honest, but plenty of rows only fail at the stream-url step, and that memory is
what stops the queue re-offering them. Session-only on purpose -- the reference
kept the equivalent set in localStorage, never expired it and never revalidated
it, so a track Qobuz restored stayed dead forever.
Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag,
favourite and download, and STAY visible and right-clickable: hiding them would
defeat the point and remove the only route to a replacement. Qobuz's own player
does exactly this, and the pulled track carries displayable:false while still
being rendered -- that flag is not a hide instruction.
A downloaded copy is exempt everywhere. The offline tier resolves before any
availability question, so a pulled track already on disk plays fine; dropping it
from the queue would take away something the user downloaded, and it would bite
hardest offline. Those rows say they are playing the local copy and keep their
play affordance.
Two seams filter, not one, and both had to learn the surviving anchor: every
caller read `first_id` from the UNFILTERED list, so the seam dropped that row
and the app then asked the player for the id it had just discarded -- silence,
no toast. That was already reachable today through the artist blacklist. The
seams now return the surviving start and its id.
Two id bugs surfaced on the way and are fixed here. A playlist row's
`playlist_track_id` was filled with the CATALOG id; they are different numbers
for the same row, one naming the recording and the other its slot in that
playlist, and Qobuz's delete and reposition verbs both take the membership id --
so "remove from playlist" was addressing a row that does not exist. And the For
You rows were built with `..default()`, claiming availability while the queue
built from the same track read the truth.
Catalogs: the two strings this needed already existed in all seven translated
locales and are reused; the skipped-tracks toast is a plural pair, and the
replacement feature's strings ride along here since they share the files.
cc
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
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
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
Ports the whole playlist-organisation domain, which had no counterpart in
this frontend, plus the Library work the owner asked not to defer.
Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the
already-ported folders_qt repository, which until now had zero consumers.
Grid, list and folder-tree modes; sort, filter and search; folder cards and
chips; drag reorder and up/down arrows. Reached from the sidebar's "..."
menu, whose New folder / Manage playlists / Import rows had been rendering
dimmed and inert behind a comment saying the seam did not exist.
Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset
or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls
its LOCAL members' folder_id explicitly, because the app keeps the
foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for
them.
QbzPlaylistEdit. One shared rename/description/delete editor replacing the
inline popup in PlaylistView. Three corrections the old path had wrong: the
description is carried through a rename instead of being written as NULL,
a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and
silently no-ops on a playlist you do not own), and the post-delete back
navigation is conditional on actually being on that playlist's page, so
deleting from the manager no longer navigates you off it.
Importer. Spotify / Apple Music / Tidal / Deezer, over the shared
qbz-playlist-import crate.
Picker local mode. The reference's four-case matrix — target local or Qobuz,
payload local refs or Qobuz ids. Local playlists list unconditionally and
Qobuz ones only when online. Local refs onto a Qobuz playlist write the
library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the
0-based enumerate the reference used before collided slots and lost rows.
The carried payload can no longer be parsed as a catalog id when it is a
library row id. Unblocks the local bulk add-to-playlist.
Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab
persistence and multi-select. Preferences land in the same
<data-dir>/qbz/favorites_ui.json the shipping build writes, so switching
between the two frontends keeps them. LibraryView is split into
views/library/ on the way past 1800 lines.
Local library. Group-by now persists; playback queues the order on screen
rather than the SQL page order; switching album-identity mode invalidates
the Artists tab; artist matching folds diacritics and compares normalized
credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being
two artists.
Offline. activate_offline never persisted a last-user marker, so every
per-user path resolved to None and all local reads came back empty; a
session that started offline saw no local playlists at all. The sidebar's
load-once latch was function-local and therefore unreachable from the logout
reset, so a second login never rebuilt the tree. Both are session-scoped
now, and the sidebar Refresh row runs the local-safe verb instead of being
dimmed with no way to recover.
Also fixes, found while verifying rather than reported: artwork derivatives
were written straight to their final path, so a QML Image resolving one
mid-encode opened a truncated png and Qt cached that failure against the URL
for the rest of the session; Library's track group separators referenced a
Component declared in another delegate's scope and could never render; the
genre filter was drawn on Tracks and Albums but applied only on All; an
All-feed row called a player method that does not exist; and the sidebar's
drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot.
17 new msgids across all 8 locales.
cc
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
Three of the owner's visual reports, one theme: geometry that was derived
in two places and drifted.
The track table laid its header out from the LABELLED columns only, so
the heart and the overflow menu took no space there and every label sat
right of the column it names. Header and rows now come from one shared
set of widths, including the responsive arms -- if the header hid columns
on a different condition than the row, the drift would come back only on
narrow windows.
The collapsed rail goes back to Slint's 64px. It had been 50 by owner
request, and the measurement showed why that was the actual defect: the
rail centred its row at 25 while the header's own sidebar toggle, sitting
directly above it, centres at 30. Symmetric padding inside a 50px rail
still reads as misaligned because the eye compares the glyphs to the
header control, not to the rail; chasing it from inside bought the
alignment at the price of a visibly asymmetric hover pill. At 64 both
references land 2px apart and the padding is symmetric again. The row
stays a literal 34 rather than being derived from the width -- deriving
it is what coupled the two tokens and would have silently grown every row
to 48.
Tooltips: the port had none, while Slint has a shell-level overlay. This
adds the overlay rather than a Popup per call site.
cc
Two defects with one cause: a tint name that means a frozen hex.
"primary" is the legacy alias of literal #ffffff, not Theme.text-primary,
so every control passing it drew a white glyph. On the eleven light
themes that is white-on-light -- the header back/forward chevrons, the
sidebar toggle and the carousel arrows the owner marked. They now pass
the theme-following names, which icon_tint_qt bakes at runtime.
The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that
predate the runtime tinter collapse into those names, and the qrc floor
underneath them becomes polarity-aware: dir_for() returns "" whenever
the cache dir is unavailable, a bake fails or the theme document has not
been published, and that path used to resolve textPrimary straight back
to the white bake -- reproducing the bug it was fixing, app-wide, in the
degraded state.
On accent fills the fix is a deviation from Slint, approved by the owner
after measurement. A fixed white glyph on Theme.accent falls under 2.6:1
on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and
wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast.
Slint hardcodes #ffffff there (CircleAction.slint:72 and three others),
so the bug is upstream. accentText alone is not the answer either: it is
picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn,
worse than the black it replaces. So QbzTheme picks the best-contrast
tint by real sRGB luminance against the live accent, and the labels
beside those glyphs use its colour twin -- BrowseGenreButton and
LibraryView were drawing a white glyph next to an accentText label
inside the same pill.
Rounds out the two parity items the same files owe: QbzNavButton gains
the ambient arm from Carousel.slint (wired at the six carousel sites,
not left dead), and QbzCircleAction rounds its glyph to whole pixels the
way CircleAction.slint does.
cc
**Track-row buttons did nothing.** Declaration order is z-order in QML, and the
row-wide MouseArea added for right-click was declared last — so it covered the
heart, cloud and menu buttons and swallowed their presses.
propagateComposedEvents cannot rescue that: a press is not a composed event,
which is why only the row's own double-click still worked.
**Covers appeared only after a scroll.** Three compounding defects, not one. The
window report fired on contentY and on model change, both of which happen while
the tab body is still hidden behind its loading flag, so the report that
mattered hit the not-visible early return and was discarded with nothing to
re-fire it. The debounce reset on every change, so a burst of mount-time reports
kept pushing its trailing edge out. And a single pending slot meant that when
two surfaces are mounted at once — the artist rail and that artist's grid — the
second overwrote the first, so one pane was never requested at all. Eviction had
the same shape: each report deleted everything outside its own array, so the two
panes deleted each other's covers.
**Latest release had no cover** because the artist page's collector skipped it,
along with Appears On and the playlists strip.
**Light themes.** The mechanism is Slint's, from 2.0.2 and issue #638: the
on-surface arm on circle actions, header-light deciding the header foreground
only when a dark backdrop is actually painted, and the circular backing behind
the track-row play glyph. Ported as specified rather than approximated. Two
icons rendered in their SVG's own colour because they pointed at the raw asset
instead of a baked tint — a sweep found the rest of that class.
**Navigation flyouts** exist, one component serving both the sidebar and the
header nav modes.
**Filter by genre and the Discover sort now work**, everywhere the control is
drawn. The genre ids were already a parameter on the discover fetch that the
port always passed empty.
cc
**The empty album view.** Opening a local album from Library "All" landed on a
blank page. The mixed feed hands Qobuz and local albums to the SAME card, and
the card always called the catalog open — so a local id, which is a group key
or a filesystem path, went to /album/get and came back 404. It was in the
owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what
changed is that clearing the stale document turned "shows the previous album"
into "shows nothing", which is how it surfaced. Routing now happens in
open_album itself, using the predicate the feed already uses to route
favourites, so every call site is covered at once instead of card by card.
**Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted
from THIS page's artwork through the ported header_tint — not the now-playing
ambient triad, which is a different subject with a different algorithm.
**Artist page.** The catalog/library toggle existed but was broken: a
hand-rolled copy of the shared tab bar whose delegate walked the wrong parent
chain, so it threw on every evaluation and never took its active colours. It
now mounts the shared control. Added the JUMP TO label, the tab-strip search
with the client-side filter it drives, and the year under release-section
albums. The Network panel ran the full window height and never auto-opened; it
is bounded below the tab strip now and opens unless the content is constrained,
as Slint does. The action circles were the wrong size with the wrong glyph
colour and no overlay arm — which would have left them dark-on-dark once the
gradient landed.
**Quality in track rows.** The column rendered a bare tier; it carries the
badge now, in the one shared row component every surface mounts. playlist_qt
was the only producer not emitting the bit-depth line.
**Skeletons hand over instead of disappearing.** They cleared when the PATH
arrived, but the image still had to load and rasterize after that, so the
placeholder vanished and left an empty cell. They now wait for the image to be
ready.
**Local covers.** Most local covers ARE already the scanner's thumbnail, but
re-hashing their path produced a different filename, so each one was decoded,
resized to its own size and re-encoded into a duplicate before being shown.
That was most of the wait.
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
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