Commit Graph
222 Commits
Author SHA1 Message Date
vicrodh 66ba12bd53 fix(qt): stop a long queue freezing the app at launch
A session with 1935 persisted queue rows came up frozen for ~28s at
120% CPU, every launch, deterministically. It looked like corrupted
local state — moving ~/.local/share/qbz aside "fixed" it — but nothing
was corrupt: that is simply a queue the UI could not carry.

The chain. AppShell mounts ImmersiveView unconditionally (its own
comment: while closed it is "an invisible, non-interactive Item"), and
`visible: false` stops an item painting, never building. CoverflowPanel
reads the flat WHOLE-queue document — unlike the queue panel's, which
paginates at 40 — and runs two Repeaters over it, the forward list and
a reversed copy. So every launch built ~4000 CoverCards, each an image
with a masked layer, for a panel nobody had opened.

Two gates, because either alone leaves it broken:

  - CoverflowPanel and both QueueTabsPanel mounts move behind a Loader
    keyed on the condition they already carried in `visible:`. That is
    the launch freeze.
  - The coverflow delegates become zero-size, non-clipping slots whose
    CoverCard exists only within |offset| < 6.5 (the fan paints 3.5;
    the margin covers the 320ms ease). That is opening it, which the
    Loader alone would still freeze.

The reference does create one element per row and hides |offset| > 3
(CoverflowPanel.slint), and that is fine there: a Slint element is
cheap where a QML delegate is not. Copying its shape literally is what
cost the 28 seconds.

Verified against the real session state, queue restored, not a
synthetic one: 120% CPU -> 2.9%, and a screenshot of Discover fully
painted where it used to sit on the skeleton.

cc
2026-08-18 07:21:03 -06:00
vicrodh a4c29c796c perf(qt): build the tab that is showing, not all of them
Entering Discover froze the UI thread for 1.52s — not slow, frozen:
zero pixels changing anywhere in the window — where the Slint build
painted the same page, artwork included, in a single frame. Measured
frame by frame from a screen recording, with the click located at the
moment the nav flyout disappears.

`visible: false` hides an item; it does not stop QML from building it.
HomeView declared its four Discover tabs as sibling Columns gated only
on `visible:`, so every mount instantiated all four and 19 of its 28
section rails belonged to tabs nobody was looking at. Gating each body
on a Loader's `active:` cut a Home mount from 426-512ms to 182-190ms,
and the tab block itself from 364-444ms to 134-140ms.

Synchronous, deliberately: incubation is time-sliced, so `asynchronous`
spreads the same work over more frames instead of removing it.

Three more of the same class, found by auditing for it:
  - SlimGrid's delegate Loader had no `active:`, so it built a full
    card for the items past `total` and then hid them.
  - LocalLibraryView mounted all four tab bodies, the Tracks one being
    the 16K-row view.
  - AlbumInfoModal built a credits row per track of the album even when
    the Description tab was showing.

ContentRouter's header claimed the fix was to keep views alive across
navigation. It is not, and the references say so: Slint destroys its
view tree with `if` (AppShell.slint:426) and Tauri removes it from the
DOM (+page.svelte:6286), and Tauri even refetches from the network on
mount while still transitioning instantly. Neither gets its speed from
caching the view — both keep the mount proportional to one viewport.
The rule is the SIZE of a mount, not its lifetime. Paragraph rewritten
to say that.

cc
2026-08-17 17:49:11 -06:00
vicrodh e779b6ec1e fix(qt): a pulled track's menu offers nothing that leads nowhere
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
2026-08-17 15:37:12 -06:00
vicrodh b2c74c4140 feat(qt): find an available version for a track Qobuz pulled
Reachable from a dead row's context menu, in a playlist the user owns. The
reference had this and shipped it with four defects; none are ported.

It was unranked -- Qobuz's raw relevance order shown to the human. The weighted
matcher that already lives in qbz-playlist-import does the ranking now, and its
ISRC short-circuit matters most: licensing churn usually republishes the SAME
recording under a new id with the SAME ISRC, and the pulled track keeps its
isrc (confirmed in the 2026-08-17 capture of album 0886443985094). That case
needs no human judgement, so the ISRC lookup runs first and pins its hit to the
top.

It removed before it added, so a failed add lost the track outright. The order
here is add, reposition, remove.

It did not preserve position -- the computed index went to a console.log and
the replacement landed at the end. It lands where the dead row was, through the
new updateTracksPosition verb. Every failure in that step is a warning and a
different success toast: the repair still stands at the end of the playlist,
exactly where the reference always put it. Position is a nicety, never a reason
to abort a repair.

Two guards. If the ISRC lookup answers with the dead track itself -- it can,
since a pulled track keeps its metadata and search still indexes it -- then
"replace" would be a no_duplicate no-op followed by a remove, and the user
would lose the row while being told it was repaired. And when the remove fails
the playlist permanently holds both rows: that is not rolled back, because a
rollback is a second write that can fail the same way and would delete the only
good copy. The list is refreshed so the user SEES both, and the toast says the
old one has to go by hand rather than claiming a reload will fix it.

cc
2026-08-17 14:41:39 -06:00
vicrodh 7780efa5db feat(qt): mark tracks Qobuz pulled, and keep them out of the queue
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
2026-08-17 14:41:26 -06:00
vicrodh 05c5799192 feat(qt): Detected device limit row, its refresh triggers and cache drop
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
2026-08-17 11:09:37 -06:00
vicrodh 5d1448b23e feat(qt): pin the award page's controls in a nav bar
Search, shuffle and the award picker are one right-aligned cluster in a
pinned 44px bar now, given the ArtistView jump-nav treatment: its own fill,
a 1px bottom hairline, the page's 32px rhythm, and a slot in the flow that
reserves its height so the hero does not jump when the bar is lifted out.

Order inside the cluster is left-to-right from "narrow this" to "leave
here": the search and the shuffle act on the page you are on, the picker
takes you off it.

Carries the rounded-corner rule this codebase has hit twice already
(ArtistView.qml:2795, the Discover full-bleed toolbar): pinned at the pane's
top edge the bar's square corners poke out past the shell's rounded bezel,
so the top corners round — but only WHILE it is at the top, driven off the
live y. Mid-page the bar is surrounded by content and a notch there is worse
than the square corner it fixes.

The picker still tracks the OPEN award: `currentIndex` is the document's
`catalogIndex`, so navigating to another award moves the selection with you
instead of stranding it on the one you came from.

cc
2026-08-16 20:12:45 -06:00
vicrodh 2d33a4fdfb feat(qt): search, load more and random on the award landing page
Three controls so the landing is usable without bouncing through "See all".

Search sits in the section header and shares the LISTING's query — one
award, one filter — so what you type here is still in force behind See all.
Client-side over the loaded set, like the listing and like the reference.

Load more is a BUTTON, not infinite scroll. The listing sub-view owns the
scroll-driven paging; this page has the "Other awards" rail under the grid,
and auto-loading would push that rail out of reach forever. Suppressed while
a query is active, for the listing's reason: paging in rows the filter then
hides is work with no visible result.

That button needed the preview cap gone. The landing document published
`take(PREVIEW_PAGE_SIZE)`, so "load more" would have fetched rows the view
threw away. The first fetch still asks for one preview page, so the first
paint costs what it did before; the cap is now only on the REQUEST, not on
what the document carries.

Random plays one album out of the LOADED set (the LibraryToolbar precedent:
pick a visible row, `QbzPlayer.playAlbum`). Not the whole award — the rest
is not here to pick from, and reaching for it would put a blocking fetch
behind a button that should feel instant.

Every mutation republishes BOTH documents now: they share one accumulating
album list, so a page loaded or a query typed on either side is visible on
the other.

No new msgids — "Search albums & tracks…", "Load more" and "Loading…" are
all in the eight catalogs already.

cc
2026-08-16 19:54:15 -06:00
vicrodh 1f636679d5 fix(qt): "Play all next" enqueues the artist instead of replacing the queue
The artist ⋯ menu offers "Play all next", "Add all to queue" and "Shuffle
all". The first was wired to `playArtistTop(false)` — which the bridge's own
doc calls "Play all": it REPLACES the queue and starts playing. There is no
plain "Play all" row in that menu, so there was no way to get the intended
behaviour, and asking to queue an artist next wiped whatever was queued.

The invokable takes a mode now and both rows route through the shared
insertion funnel: "next" inserts at the cursor, "queue" appends. That is
what the reference does (`enqueue_artist_top_selected(.., next: true)`) and
what `playback_qt::enqueue_artist_top_mode` was written for — it had no
caller at all, which is how the miswiring survived.

cc
2026-08-16 18:56:38 -06:00
vicrodh dc2496e5c0 feat(qt): award pages, reachable from the album sidebar and from each other
Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine
line, follow heart, "Acclaimed Releases" with its count and See All, the
preview grid and the "Other awards" rail) and the paginated listing with
its client-side search. Routes "award" and "awardalbums", the label pair's
shape — one controller for both, since they share the hero, the follow
state and the identity.

The three rules from the Tauri review are in the module header because they
are the ones a re-implementation gets wrong: the grid is ALWAYS
/award/getAlbums (/award/page is user-scoped and may omit releases, so it
feeds the hero and nothing else); the favorite split is plural-read,
singular-write, because the backend derives `award_ids` from the write
kind; and getAlbums has no has_more, its `total` is a hint, so the only
sound predicate is `total > loaded`.

The album sidebar's AWARDS cards navigate now — with an id directly, and by
NAME through the resolver when Qobuz omits it, which it does. They were
also reading `album.awards` when the field lives on `album.header`, so the
block had rendered EMPTY for every album since it was written: an empty
Repeater draws nothing and reports nothing, and the sidebar's own gate
counted the same empty list. The LABEL card beside it had a "no label view
yet" note and no handler, years after the label view landed.

Beyond the reference: an all-awards dropdown pinned to the page's top-right,
so any award is reachable without first finding an album that won it. It
costs nothing new — the /award/explore crawl already existed to resolve
names to ids.

The award icon was lucide's MICROPHONE under the wrong filename. Search had
routed around it with `crown` and left a note; kiosk was missed and drew a
mic for "Most popular". The asset is the laurel the other two frontends use,
and kiosk now asks for the crown it wanted.

cc
2026-08-16 18:56:20 -06:00
vicrodh 60bf5b8365 fix(qt): "Not interested" actually removes the artist from Recommendations
The write existed — `dismissArtist` persisted to the reco-dismissal store
and toasted — but nothing READ it: `reco_dismiss_qt::ids_snapshot()` had no
consumer outside its own module, so the artist came back on the next
rebuild. The card's comment promising it would "leave the rails on the next
publish" was false, because nothing filtered on publish either.

The dismissal ids now join the exclusion set fed to `compose_artist_rails`,
so the composition choke point can backfill the freed slot from the pool
instead of leaving a hole, and `apply_dismissal` drops the card from the
cached rails on the CLICK — no optimistic flip in QML.

Scope is deliberate and recorded on the card: RECOMMENDATIONS only. The
same card is mounted by Search, Label, Artist Scene, Home and the Library
grid; dismissing from one of those persists and takes effect in
Recommendations, but the card you clicked stays where it is. Hiding an
artist app-wide is the blacklist — a different store and a different row.

cc
2026-08-16 18:54:28 -06:00
vicrodh 41c1b50d61 feat(qt): offline cache manager, and stamp the album identity on cached tracks
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
2026-08-16 18:54:16 -06:00
vicrodh ffa7cfd4c4 feat(qt): Library All local scope, and stop resolving local rows as Qobuz tracks
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
2026-08-16 18:53:56 -06:00
vicrodh bbbac0a0ad feat(qt): auto-theme custom image picker, and retire the fossil settings notes
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
2026-08-16 18:53:37 -06:00
vicrodh 9899ad6b09 feat(qt): open the playlist importer from the Playlist Manager toolbar
The importer had exactly two doors, both inside the sidebar's playlist
popup, so the one view whose job is managing playlists could not reach it.

Placed before "New folder": that slot disappears in flat mode and a Row
skips invisible children, so a button after it would slide sideways every
time the mode toggles. No offline gate — the modal recomputes its own and
shows a disabled confirm with the reason, which beats a toolbar button
that vanishes when the network drops.

No new msgids: "Import" is already in all eight catalogs.

cc
2026-08-16 18:53:21 -06:00
vicrodh 16a25bbbe6 fix(qt): pin the Local Library tracks scrollbar to the view edge
The bar sat inside the tab's 32px content inset AND carried its own 4px
margin, leaving it 36px from the window with an empty channel beside it.
It now cancels the inset; the rows keep theirs.

The "By name" arm is untouched: there the alpha strip owns the right edge
and 22px is what clears it. The inset is a named property now because the
scrollbar has to cancel it — written twice, moving one silently misaligns
the other.

cc
2026-08-16 18:53:04 -06:00
vicrodh 13f1b106dc fix(qt): keep the Local Library select button clear of the search box
The expandable search keeps a 30px footprint and grows its inner field
LEFT (QbzLineEdit.qml, `x: root.width - width`), so anything positioned
before it in the row is covered while the search is open and cannot be
clicked. On the Tracks tab the multi-select button led the row and was
exactly that.

Moved to the end of the group — the slot the Albums tab already puts it
in, so the two tabs agree as well.

Swept the other nine expandable searches: in all of them the field is
already the first child of its row, and the tree rail's box width is
computed to stop at the third button. Tracks was the only case.

cc
2026-08-16 18:53:04 -06:00
vicrodh 3bbe7a039d fix(qt): album info opens for every album, not only numeric ids
The guard had its predicate backwards. `album_id.parse::<u64>()` rejected
the four-in-five Qobuz albums whose id is alphanumeric ("vy32kidwrer67",
114 of the 139 ids in the captured fixtures) while admitting local library
row ids, which are exactly what it claimed to be blocking.

A rejected open returns before touching the bridge, so the modal kept
whatever document was there: the previous album's, or — once the QML
started holding its overlay until the ids matched — an empty card. Neither
said a word above debug level, which is the whole of "only the first album
info of a session works".

The published document now stamps `albumId` with the id that was ASKED
FOR rather than the one /album/get echoed back. The QML compares those two
with exact string equality, so any divergence blanked the card silently;
a mismatch now logs instead.

Regression tests cover the four real alphanumeric ids, the barcode-shaped
minority the old guard did accept, and the local/Plex keys the guard
exists for.

cc
2026-08-16 18:52:52 -06:00
vicrodh 7d98ad044f feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON
document per screen plus a revision counter, so QML re-parses even when the text
is byte-identical.

- The list: two tabs with per-type counters, a server-side search on a 300 ms
  debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort,
  direction, two filters and grid/list; Tracks gets group and the filters only.
  Building the Albums toolbar on both would have invented two controls.
- The detail: a format dropdown whose selection re-scopes every downloaded mark,
  the progress section and Add-to-Library together; a determinate bar counted in
  TRACKS, never bytes; goodies as an album-level action counted separately.
- The album download is the frontend loop, not the Rust command the reference
  registered and never called. Sequential, concurrency 1, cancellable, and a
  failed track does not abort the album.
- Both prefs are read at runtime, not merely written. `show_purchases` is the
  master gate and `nav_tb_purchases` only relocates the entry to the title bar;
  both default false, so Purchases still ships hidden.
- Scanned local rows keep their raw `qobuz_purchase` source so the gold badge
  survives in list mode as well as grid, while still classifying under the
  Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping
  them would badge every purchase the user merely OWNS as locally downloaded.

Defects fixed after the screens were written, none of which any test could have
caught:

- `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every
  publish is a silent no-op, so both screens would have stayed permanently empty
  with nothing logged. The cause was an ownership gap rather than the code:
  `Main.qml` belonged to no lane because the plan listed the files each lane
  CREATES rather than the files it TOUCHES, and "belongs to nobody" is
  indistinguishable from "done" until something runs.
- A second download of the same album nested inside the first. The store rewrites
  its destination to the album subfolder after the first successful track so that
  Add-to-Library adds the album rather than the whole music root, and the download
  planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`,
  one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are
  now separate fields with names that cannot be confused, with a regression test.
- A tab counter could stick at zero forever with its list plainly full: the two
  ids calls each swallow their error, and the reference's backfill from the list
  response was missing.
- Cancelling made the whole progress block vanish instead of showing the
  cancelled state, whose msgid was already translated in all eight catalogs.
- Add-to-Library re-walked the entire library when the folder id it needed was
  already in hand.
- A live language switch and a logout left the previous state behind.

Deliberate divergences from the contract, each recorded with its reason: the page
header is a plain `fontSection` title with no icon tile, because nothing else in
this application announces itself over a large coloured square; purchase download
status travels inside the album document rather than on the shared cache channel,
which is keyed by track id alone and would let the two features overwrite each
other on the same row.

Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p
qbz-qt` 398 passing, offscreen smoke green with zero QML complaints.

Not verified, and cannot be from here: every populated screen. This account owns
nothing, so only the empty states have ever rendered.

cc
2026-08-16 11:54:05 -06:00
vicrodh f701bcd4db fix(qt): size-aware now-playing artwork, up to the mega tier
Restored queues and recently_played carried whatever variant the building
surface picked — 50px JPEGs measured in the wild — into the now-playing
bar, MPRIS, Discord and the immersive big slots. The np feed rewrites
sized Qobuz covers up to 600px; big slots (immersive panels, split,
preview overlay) resolve per size bucket through npArtworkPathLarge with
one re-resolve per tier crossed and never on shrink.

Registry-miss arm (restored sessions carry no ImageSet): rewrite to the
FULL requested bucket with a session negative cache, so a pre-mega album
pays one 404 per session instead of one per track change. And the mega
tier's wire suffix is the literal _max.jpg, not _3000 (404 measured
against the live CDN) — qobuz_cover_at_px now accepts and emits _max.

Local covers gain an on-demand 1600px tier (never upscaled); Plex big
slots tokenize at 1024.

km
2026-08-16 08:23:15 -06:00
vicrodh e6c058c2f2 feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral
Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven
ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed
by the Viz16 stream. Scene rows live in the header view menu (no Off row);
g cycles the ring. A fresh immersive open defaults to Split > Lyrics
(owner ruling).

OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as
type buf and type buf"): every ShaderEffect fragment with its own uniform
layout now ships a paired vertex stage declaring the identical block —
the shared spectrum.vert only ever linked under Vulkan.

RHI hardening: render pass descriptors on the ping-pong targets (the
Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the
backend (!== OpenGL), ribbon sub-uploads carry setSourceSize.

km
2026-08-16 08:22:57 -06:00
vicrodh 8bd02e35d3 feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
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
2026-08-15 00:52:25 -06:00
vicrodh 8ff0adc149 fix(qt): convert the Slint colour literals that made scrims and shadows invisible
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
2026-08-15 00:51:18 -06:00
vicrodh 016eb3cd23 feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.

- qbz-core gains scene discovery ("artists from the same place"): a
  cancellable run with a phase for the progress label and a typed
  SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
  an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
  are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
  and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
  bridges. The five-branch musician dispatcher is wired to all six of its
  consumers. Both scene doors are live: the sidebar location link and the
  overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.

Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:

- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
  the engine; now_playing::set_volume has two callers and neither runs in a
  local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
  while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
  audio data available") for want of a has_loaded_audio() guard. Investigation
  in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.

Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.

cc
2026-08-14 19:25:11 -06:00
vicrodh b045f7023b chore(qt): vendor the country flag set used by the artist scene
265 SVG flags (1.1 MB) plus the upstream MIT licence and attribution in
crates/qbz-qt/third-party/. Kept as its own commit so the asset drop can be
rolled back or re-sourced without touching the scene code that consumes it.

cc
2026-08-14 19:24:58 -06:00
vicrodh 085c35876a revert(qt): make the content router synchronous again
An asynchronous Loader plus a router-level skeleton was meant to fix the dead
click on navigation. It made the app markedly worse and is reverted whole.

Async incubation is TIME-SLICED: it yields to the UI thread between slices, so
it trades a short freeze for a much longer wall-clock build, and the Loader
stays in Loading until the entire tree exists. For Discover > Home that is all
413 cards -- a one-second freeze became a five-second skeleton on the most used
route in the app. Measured after the fact, which is the mistake; a change that
moves WHEN something heavy is built has to be timed on the busiest route before
it is called a fix.

The placeholder is gone with it. It drew the same two text lines and row of
five cards for all 40 routes, so the swap moved everything -- the exact jump a
skeleton exists to prevent.

The dead click is real and remains unfixed. The durable fix is not here: stop
rebuilding these views on every navigation (keep the top-level tabs alive) and
build the heavy views incrementally. The reasoning is kept in the file so the
experiment is not re-run.

cc
2026-08-13 23:10:49 -06:00
vicrodh fda56f9ea2 fix(qt): pin the artist jump nav bar and round it at the pane edge
The strip scrolled away with the body; the reference clamps it to the top.
Lifted out of the flow with a slot holding its place, using the reference's
clamp verbatim -- the formula must not add the viewport y or the scroll is
double-counted and the bar lifts ahead of the body mid-transition.

Pinned at the pane's top edge it then showed the square-corner defect already
fixed in Discover: Qt's clip is a rectangular scissor that ignores radius, and
the shell hides its bezel nubs while the dynamic background shows through the
corners, so a full-bleed child has to round itself. Driven off the live y, so
the rounding exists only while the bar is actually at the top -- rounded
corners in mid-page would be a notch with nothing behind them.

cc
2026-08-13 23:10:40 -06:00
vicrodh c2944db973 feat(qt): source-aware playback, session persistence and loading affordances
One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.

Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.

Session persistence, both levels the port was missing:
  - The startup-page preference is finally read. It had been rendering and
    persisting a value nothing consumed. `last_view` is written in the SHARED
    ui_prefs vocabulary, not this port's route ids -- four of the six differ,
    and writing the raw ids would have silently broken the other frontend's
    restore. `on_session_entered` recorded "home" unconditionally, which also
    rewrote last_view on every boot, so the setting could not survive its own
    startup.
  - Queue and active track survive a restart. session_persist was tied to a
    frontend by exactly one type alias, so it moved to qbz-app generic over the
    adapter rather than being copied. Verified end to end: the snapshot restored
    here had been written by the Slint binary, and the exit flush keeps an
    un-consumed resume position instead of overwriting it with the engine's
    idle 0.

Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.

Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.

cc
2026-08-13 23:10:32 -06:00
vicrodh 3b03682e09 fix(qt): pane corners stay round under the dynamic background
AppShell hides its BezelCorner nubs while the field is meant to show
through the pane's corners, and Qt's clip is a rectangular scissor that
ignores radius — so any full-bleed child at y=0 squares the pane. The
doctrine was already written in AppShell.qml ("a full-bleed pane child
must round itself; there is no mask that can do it for it here"); these
eight surfaces predated it or, in the two thin bars added with the
ambient port, ignored it.

- The seven fixed 56px headers (Home, Search, DiscoverBrowse,
  ArtistReleases, PlaylistBrowse, PlaylistManager, PlayHistory) take
  topLeftRadius/topRightRadius. Invisible with the background off, where
  the view root paints the same colour beneath. PlaylistBrowse's SECOND
  bar is the category strip at mid-page and keeps square corners;
  PlayHistory keeps its unconditional opaque fill, which is reference
  parity (:43/:34) and a separate question from the corner.
- LocalAlbumView's neutral header band is now gated on the background,
  1:1 with the Qobuz page's atmosphere (AlbumView headerAtmoOn,
  AlbumPageView.slint:168): the two dark backdrops clash and the field
  already provides one. That band had been square since it was written;
  the nubs had been hiding it, and they only stand down when the field
  is showing. Header text there is theme.textPrimary, not the Qobuz
  page's light-on-dark hdrStrong, so it does not depend on the band to
  stay readable — checked before removing the backdrop from under it.

Gate: cargo build -p qbz-qt, both QML audits, offscreen smoke 0
complaints.

cc
2026-08-13 14:33:35 -06:00
vicrodh eb80e2f367 feat(qt): animated now-playing indicator in track rows
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
2026-08-13 13:02:10 -06:00
vicrodh a2a99a72cc perf(qt): bring the immersive onto the pulse (83% -> 25% GPU)
- AlbumReactivePanel: drop the eight 100ms Behaviors on 30 Hz data (a
  retriggered Behavior animates at display rate and every animation
  frame is a full-window present), and read the settle — not the
  publishing bridge list — in the energy formulas (the .length guard
  re-evaluated the visual bindings on every FFT publish).
- LyricsSyncEngine: the fast cadence rides the pulse; the 250 ms idle
  re-check keeps its own timer.
- EqualizerBars: the phase accumulates on the pulse and the 'active'
  gate is host-provided (invisible subtrees still dirty the window).

Measured on the owner's machine, immersive reactive panel open:
132 -> ~33 presents/s, 83% -> ~25% GPU.

km
2026-08-13 13:02:10 -06:00
vicrodh f46c5c95d4 perf(qt): one repaint pulse for the whole shell (GPU 95% -> 25%)
Qt Quick has no partial redraws: any dirty item presents the WHOLE
window, and on the owner's hybrid laptop each full-window present costs
~1.2% GPU flat (KWin composite + dGPU scanout; area-independent, while
the app's own render is 0.08 ms/frame). The bill was presents/s, and
the shell ran unsynchronised ~30 Hz clocks plus two leaks:

- QbzShell.pulseMs: ONE Rust-driven 33 ms edge that every continuous
  animator ticks on (knob QBZ_PULSE_MS). VizSettle now stashes on
  publish and applies on the pulse (its 16 ms interpolation timer is
  gone); AmbientField and ImmersiveAtmosphere ride the same edge.
- VizSettle writes NOTHING while not live: the always-mounted but
  invisible immersive panels were dirtying 28 bar rects per FFT
  publish (named via QT_LOGGING_RULES=qt.quick.dirty=true).
- AmbientField renders inline again: ShaderEffectSource
  scheduleUpdate() double-presents (minimal repro fbo_repro.qml in the
  investigation folder).
- Background mode 2 (ImmersiveAtmosphere) composites its four drifting
  layers + base + dim + scrim in ONE opaque ShaderEffect
  (atmosphere.frag, baked by build.rs); numeric parity vs the Image
  stack verified (atmo-shader-parity-check.py, delta 0.0). The
  four-Image stack stays as the software/failure fallback.

Measured on the owner's machine: 93-97% -> 25-26% GPU, ~34 W -> 12-13 W,
~33 presents/s in both background modes. Full story:
qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/GPU-COST-INVESTIGATION.md

km
2026-08-13 13:01:50 -06:00
vicrodh b136c9cf5d feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).

The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.

km
2026-08-13 13:01:32 -06:00
vicrodh 21e941bdf6 feat(qt): renderer tier, GPU selection and a real shader build
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
2026-08-11 13:59:24 -06:00
vicrodh 8ba9483462 feat(qt): HiFi Wizard — guided DAC setup ported from Slint
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
2026-08-11 11:26:50 -06:00
vicrodh 79e3cdc46b feat(qt): multi-select bulk bar on every Qobuz track listing
The album-page pattern (selection in QML, actions in Rust) generalized:

- New shared engine bulk_tracks_qt.rs: one catalog batch-resolve that
  preserves the selection's visible order, then queue / play-next /
  play-later / add-to-playlist / add-to-mixtape / add-to-favorites /
  make-offline, with the queue context stamped per host.
- PlaylistView: toggle in the header action row, bar inline above the
  column header, selection resets when the playlist changes.
- ArtistView: the Popular Tracks header's dimmed toggle goes live; one
  selection covers Popular Tracks + Appears On + the in-library rows;
  the PopularTrackRow fork gets the select checkbox AND its offline
  cell/menu block stop being inert stubs.
- LabelView: Popular Tracks gets the same treatment.
- AlbumView: the bar moves from a floating overlay to the inline spot
  the Local Library album view uses (bar in flow, content below).

km
2026-08-11 09:00:01 -06:00
vicrodh d112c726b6 fix(qt): custom album covers propagate to every surface
The per-builder sweep was whack-a-mole — the report caught empty cards
in Home > Recently Played Albums and the NPB, and mosaics still showing
the Qobuz art. Overrides are keyed by album id but most surfaces carry
only the art URL, so the link is now the artwork HASH: every size
variant of one Qobuz cover shares its filename stem. Setting a cover
records hash -> path in a Qt-side store (custom_cover_keys.json — same
reason as the playlist store: the Slint file round-trips known keys
only), the album page backfills it for covers set before the map
existed, and artwork_qt's cached_path / cached_raw_path answer the
override for any URL whose hash has one — cards, NPB, MPRIS, queue,
collage tiles, no per-surface wiring. download_missing skips overridden
URLs.

km
2026-08-11 08:59:36 -06:00
vicrodh a53e8a7af7 feat(qt): custom covers for playlists + the app-wide custom-art sweep
Two halves of one feature:

- Playlist custom covers (Qt-first; no Slint counterpart yet): the
  playlist header cover gets the cover menu (Add/Change/Remove) and the
  lightbox on left-click. The store is a NEW file,
  custom_playlist_covers.json — a `playlists` key inside the shared
  custom_artwork.json would be silently dropped the next time the Slint
  app wrote an override (its store round-trips only known keys). The
  override beats the playlist's own Qobuz graphic AND the member-cover
  mosaic on the header, the sidebar rows, the cards and the library
  grid.
- Album custom covers now apply everywhere, not just the album header:
  doc builders route an album's art URL through
  cover_artwork_qt::prefer_album_cover (the override's path flows
  through the ordinary artwork pipeline — artwork_qt::classify's
  LocalFile arm resolves it synchronously, no QML change). Swept: album
  page carousels (more-from-artist / suggestions / Last.fm similar),
  artist page releases, search result cards, and the playlist mosaics
  (a member album with a custom cover contributes THAT art to the
  collage tiles).

km
2026-08-11 01:38:18 -06:00
vicrodh 83b8bd8890 feat(qt): offline downloads + multi-select on the album page
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
2026-08-11 01:38:18 -06:00
vicrodh 704fd7e60a fix(qt): artist Latest-release card renders its data again
The card bound `artist.lastRelease.*` unqualified, and inside an
AlbumCard instantiation the bare name `artist` resolves to the CARD'S
OWN string property — a string has no .lastRelease, so every binding
fell back to "" and the card rendered as an empty tile (section visible,
no art, no title) while the grids' modelData-driven cards were fine.
Caught live on a VNC-driven session: the published document carries the
full lastRelease object; only the QML scope shadowing ate it. Every read
in the block is now root.artist.lastRelease.

km
2026-08-10 22:08:43 -06:00
vicrodh af95ce164a fix(qt): search top-result track hero no longer invades the Artists rail + crown icon
Two Most-popular regressions on the search results page:

- When the top result is a TRACK, the hero painted from its slot's center
  ~100px into the Artists carousel: SearchTrackHero's root Rectangle had
  no size, and a bare Item's implicit size is 0x0 (it does not size from
  children), so the slot's horizontalCenter anchor pushed the whole card
  sideways. The artist/album arms carry explicit sizes and never shifted.
  The hero now declares its 200px width explicitly.
- The header glyph was "award", which reads as a mic. The reference uses
  the crown (SearchResultsView.slint:584); crown.svg is ported into the
  icon masters (primary + the warning bake) and the header uses it.

Both verified live on a VNC-driven session with the exact "all things"
search from the report.

km
2026-08-10 22:08:43 -06:00
vicrodh 1fe20e9af0 fix(qt): album info modal never shows a previous album's document
Opening album A's info, closing, and opening B's left A's fetch in
flight with nothing stopping it from publishing A's document over B's
loading state — the modal rendered A's credits inside B's page (smoke
catch, 2026-08-10). The carousel rows in album_qt.rs already carry the
"late replies are generation-guarded" pattern; the modal now does too:
a monotonic open counter gates every publish, open() clears the stale
document with the loading flag, and the QML overlay additionally holds
until the published document's albumId matches the requested one.

km
2026-08-10 20:51:38 -06:00
vicrodh d9b1c26ff3 feat(qt): album page parity — cover menu + lightbox, header actions, album info modal
Four wired surfaces on AlbumView, all previously inert stubs:

- Cover menu (right-click, AlbumPageView.slint:300-370): Add/Change/Remove
  custom cover over the SAME custom_artwork.json store the Slint app uses
  (a cover set in one app shows in both), Open in browser, Save as…. Plus
  the NEW lightbox (menu entry + left-click): the art at its best available
  quality fitted to the window, custom override preferred.
- Header action row: radio (the existing foryou_qt album-radio seam),
  booklet (visible when a PDF goody exists; downloads via save-as — no
  in-app reader, like the reference), and add-to-mixtape/collection — the
  MyQBZ picker's Accepts engine already lets albums target Collections
  (the Tauri rule), so the button just feeds it.
- Album Info modal (the extensive one): fresh /album/get → credits grouped
  through the shared qbz_qobuz::performers parser, per-track copyright,
  review tab when the album has editorial text, label link, musician
  navigation, per-track play in album context. Tauri layout (left fixed
  meta column, right scrollable tabs) per the project ruling for this
  feature; the header's label link is wired along the way (LabelView has
  existed for a while — the POC-NOTE was stale).
- … menu: Add to playlist (the loaded view's track ids into the picker),
  Add to mixtape, Share Qobuz link, Share Album.link (UPC -> Deezer ->
  album.link, #514 path — the resolver half of share.rs is now ported for
  the album arm). The offline rows stay out: no Qt album-cache seam.

i18n: "View cover" + "Could not load the image" catalogued in all seven
locales. Gate: both QML audits + 305 tests + debug/release builds green.

km
2026-08-10 20:24:25 -06:00
vicrodh 98dfcd8173 fix(qt): nav flyout entries land on their own tab
Every flyout entry (Discover > For You, Library > Albums, Local >
Folders...) navigated to the section but landed on its default tab: the
port handed the tab to the mounted view through a depth-capped scene
search (NavFlyout.findTabHost, cap 7), and the ContentRouter extraction
(kiosk port D3) added a mount level the walk never reached — flagged as
a latent desktop regression in the kiosk audit (2026-08-02
AUDIT-REPORTS.md, finding 11) and confirmed by smoke test.

The tab now travels the way this codebase crosses documents: the
bridge. NavFlyout.activate calls the new QbzShell.navigateToTab(view,
tab), which publishes navTab/navTabView/navTabSeq, and a Binding in
ContentRouter applies it to the mounted view's activeTab — the same
pattern as the MyQBZ kind discriminator. The seq counter re-applies
on a repeated click of the same entry, the navTabView guard keeps a
cross-view request off the outgoing view, and a plain navigateTo clears
the request so section navigation never drags a stale tab along. A
click for the already-mounted view skips navigation (no duplicate
history entry) and re-applies the tab live.

Verified by smoke: cross-section, same-section, repeated-click
and plain-navigation cases all land where Slint lands.

km
2026-08-10 18:57:02 -06:00
vicrodh 479921af52 feat(qt): Offline settings 'Check now'
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
2026-08-10 17:17:43 -06:00
vicrodh eca07d4370 fix(qt): settings export honors the include-auth gate
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
2026-08-10 17:17:28 -06:00
vicrodh c198afb33c feat(qt): queue drag-and-drop insert at position
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
2026-08-10 17:16:46 -06:00
vicrodh 7754b8bc00 feat(qt): Plex PIN sign-in and connection check
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
2026-08-10 17:16:30 -06:00
vicrodh c807d24463 feat(qt): local library folder editing
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
2026-08-10 17:16:18 -06:00
vicrodh 13996a0566 feat(qt): in-app log viewer
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
2026-08-10 17:15:53 -06:00