Commit Graph
39 Commits
Author SHA1 Message Date
vicrodh 28c04bd990 docs(qt): correct the stale search notes
The two whose falsifying commit does not touch their file, so R7 rule 2
could not put them beside the code.

QbzEmptyState claimed it had consolidated SearchView's and Cortinilla's
no-results states. Neither uses it - both draw their own line inline, and the
cortinilla's is a single 28px row inside a dropdown rather than a centred
block.

SearchView's SOURCE note justified its Qobuz-only assumption by saying the
local sections "are not ported". They are now. The assumption still holds for
the results PAGE, which is Qobuz-only, so the note is narrowed rather than
deleted - the cortinilla is a different document whose rows carry their own
source field.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C12
(R7-7, R7-8).

cc
2026-08-03 18:15:16 -06:00
vicrodh 4806584ee2 feat(qt): local "View more" routes into the LocalLibrary tabs
The three local sections' View more links now leave the search surface
entirely and land on the matching Local Library tab. Sending them to the
Qobuz results page instead would show a page with no on-device content at
all, which reads as the link having done nothing.

  local-album  -> Albums tab
  local-artist -> Artists tab
  local        -> Tracks tab, pre-filtered with the live query

The query pre-filters the TRACKS tab only. Albums and artists have no search
box of their own, so handing them a query would be a lie - the list would
come back unfiltered while something claimed otherwise.

The active tab is QML-owned state, so the route is a pending property the
view consumes, mirroring the localPendingArtist mechanism that already
existed for "go to artist" on a local album. It is released after being
applied, because the property CHANGE is the trigger and an unreleased route
could not fire twice in a row. The consumer also runs on mount, so a route
set while the view was unmounted still lands.

Setting the tracks filter writes the view's own search state as well as
issuing the query, or the search box would render empty next to a filtered
list.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C9
(sections 3.4-3.5).

Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.

cc
2026-08-03 17:33:01 -06:00
vicrodh fa433d465b refactor(qt): extract search_bridge.rs from the QbzBridge god-object
QbzBridge carried five domains (Settings, Search, Playlist, Genre filter,
Discover config); its own boot() comment called the split "TEMP (phase 23)".
TRACK-RULES section 2 requires one cxx-qt bridge per domain. This moves the
Search domain out verbatim, ahead of the cortinilla parity work, so the
refactor and the behaviour change stay separately reviewable.

QbzSearch (search_bridge.rs) takes 7 properties and 11 invokables. Two
deliberate changes ride the move:

  - selected_index -> cortinilla_selected_index. It is cortinilla-only
    state and the bare name on an app-wide singleton is a collision hazard.
  - cortinilla_scroll_y f32 -> f64, unifying with the immersive twin in
    immersive_bridge.rs, which was already f64.

cortinilla_query is declared here but not yet wired; C2 wires it, which is
what makes Enter carry the text the user actually typed instead of the last
successfully loaded payload's query.

Every Rust writer of a moving property was re-pointed BY HAND, never by
regex over the multi-line ui() closures: the seven crate::ui( sites in
search_qt.rs plus two that live outside it and that the QML-only xref audit
cannot see - settings_qt.rs (the live Settings > Appearance path) and
main.rs toggle_intelligent_search. All 48 QML consumer sites across
Cortinilla.qml, HeaderBar.qml and SearchView.qml were updated, including the
one Connections block, whose onSelectedIndexChanged handler was renamed with
its property.

A stale 6.0 base in flat_index_content_y is left in place and annotated: it
mirrors a Flickable viewport margin rather than content space, so every row
reports 6px low. C4 corrects it with its unit test; this commit is a move.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C0.

Gate: build clean (no new warnings), 256/256 tests, qml_resolution_audit
214 files, qml_singleton_xref 28 bridges with QbzSearch resolving, offscreen
smoke 0 complaints + home published.

cc
2026-08-03 15:24:25 -06:00
vicrodh 5130e872e7 feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km
2026-08-03 13:32:35 -06:00
vicrodh ec8588687b feat(qt): artist page parity - load more, section sort, discography
The release-section "Load more" fetched a page and appended it to a structure
no view ever re-read. The handler pushed IN PLACE into the object behind
`readonly property var artist: JSON.parse(QbzArtist.artistJson)`, and the
releaseSections projection returned that same array on the unfiltered arm, so
both Repeaters were re-handed the identical reference and nothing was dirty.
The tree's own rule, written in cards/PlaylistCollage.qml: rebind requires a
NEW object reference. A second bug sat on the same path - the artist document
republishes progressively (stories, then each MusicBrainz section as it
resolves) and every republish re-runs the parse, wiping anything appended, so
fixing only the first would have left a bug that looks random. Both
pre-existing; the in-flight placeholder added earlier is what made an
always-silent no-op visible.

Appended pages now live in an overlay keyed by release type. It survives a
republish, emits new arrays so the Repeaters rebuild, dedupes by album id,
carries the reference's 4-page cap per bucket (artist.rs:718-720, which hands
off to the discography page beyond it), and is guarded against a page
requested on one artist landing on another's grid - releaseSectionReady
carries no artist id and every artist has an "album" bucket.

"See discography" was a pointer-cursor MouseArea with no onClicked under a
POC-NOTE saying the page was out of scope. ArtistReleasesView.qml +
artist_releases_qt.rs port ArtistReleasesView.slint: infinite scroll rather
than a button, plus the loading, error-with-retry, empty and offline arms.
The album page's "More from <artist>" view-all is a second door to the same
page. Its paging offset counts what the SERVER returned rather than the
rendered row count the reference uses: blacklist filtering and id dedupe make
the rendered count smaller, so the reference re-requests seen rows and skips
the tail whenever the blacklist is non-empty.

The per-section sort was a dimmed Rectangle with no MouseArea, under a
comment asserting no seam existed. It is now a real QbzSelect with the
reference's five options and index mapping, persisted per release type in
artist_prefs. Choosing "Default" after a sort does not restore the server
order until the page reloads - that is the reference's behaviour
(album_map.rs:262 is a bare no-op arm and resort sorts the live model), kept
deliberately rather than diverged.

Also, two visual fixes from the same round that share these files: Popular
Tracks drew its cover at radius 8 where the shared rows/TrackRow.qml uses 4,
and the album page's rail mounts said `width: parent.width` inside a Column
with 32px padding - QML padding shifts children without narrowing
parent.width, so the rails hung 32px off the right and the right page chevron
landed outside the window.

Carries scripts/qt-diag.sh, which has been sitting untracked since the
rendering round: it answers "which backend is this actually running on" from
a real graphical session, refusing ssh and refusing offscreen because either
would answer the wrong question. That refusal is the point - the doctrine it
disproved ("shader effects render nothing on this port's software path") came
from an offscreen observation, and offscreen forces software by definition.

cc
2026-08-02 16:34:58 -06:00
vicrodh edbc037b0f feat(qt): Rust share seam, and fix the dead label share link
The port had no clipboard seam at all. share_qt.rs ports the URL builders and
the clipboard from crates/qbz/src/share.rs:1-70. The long-lived
OnceLock<Mutex<Option<Clipboard>>> is copied verbatim with its reason:
arboard ties the offer's lifetime to the LAST live Clipboard object, so a
create-per-copy port passes on KDE, which runs a clipboard manager that
accepts the handoff, and silently loses the text everywhere else (#514).
arboard carries the wayland-data-control feature at the same pin the
reference uses; without it arboard is X11-only and every copy fails on a
Wayland-only session.

The label header's Share was not merely silent - it copied a link that does
not resolve. open.qobuz.com has no /label/ route. The host is per-ENTITY, not
per-app: track and album on open.qobuz.com, artist, playlist and label on
play.qobuz.com. That asymmetry is why the five builders are ported as a set
and why no call site formats its own URL any more: this one did, and got it
wrong. QbzHome::label_share now copies play.qobuz.com/label/{id} and raises
the "Link copied" toast, 1:1 with main.rs:13164-13178, and the QML
Loader/TextEdit clipboard hack it replaced is deleted - its own comment
claimed there was no toast seam, which stopped being true when toast_qt.rs
landed.

Only the URL builders and the clipboard are ported. share.rs:72-184 is the
Song.link/Odesli resolution, which applies to tracks and albums; artists and
labels are link-only, so porting it now would be dead async HTTP.

LabelView also adopts QbzLoadMore here - its two changes share the file.

cc
2026-08-02 16:32:51 -06:00
vicrodh 6f4fb72f57 feat(qt): shared QbzLoadMore with an appended-page skeleton
Five hand-rolled "Load more" buttons existed in three visually distinct
shapes. One control now carries all three arms, so the idle appearance of
every site is unchanged. While a fetch is in flight it draws a QbzSkeleton
composite BELOW the button - the owner asked for below - which is one
animator for the whole placeholder row, with settleMs 8000 so a fetch that
never returns stops shimmering instead of pulsing forever. Idle it costs
nothing: the block lives behind an inactive Loader, so mounting the control
changes no pixel on a page nobody has clicked.

SearchView had half of this already, and the half it had was on the wrong
side: a skeleton ABOVE the button, on the albums tab only. All four tabs now
use the control, each with its own real cell pitch and row shape rather than
the albums tab's numbers copied across.

AlbumCollection gains an opt-in tail fade (armTailFade) for the arrival half
of the round. It is keyed by the set of ids on screen at click time, not by
an index threshold: the label page sorts its grid, and under any order but
newest a fetched page interleaves, so a threshold would re-fade albums that
never moved - the flicker the fade exists to avoid. It also lets grouped mode
fade, where there is no flat index at all. Inert unless a host arms it, so
Discover Browse and Play History are bit-for-bit unchanged.

cc
2026-08-02 16:32:24 -06:00
vicrodh 8776ebe82d fix(qt): carry the real track data into every queue, and shuffle for real
Two defect classes the owner found by playing music, both of them the port
diverging from a reference that gets it right.

QUEUE TRACK FIDELITY. The reference has ONE builder, make_queue_track
(playback.rs:2426), and it takes the raw Track, so bit_depth and sample_rate
come straight off the API model. This port forked that into ten builders and
five of them mapped from a DISPLAY ROW instead — a row that carries
qualityTier / qualityDetail / qualityLabel, the already-formatted strings, and
no raw numbers. Those five hardcoded bit_depth: None, sample_rate: None:
playlist_qt, artist_qt, label_qt, library_qt and playback_qt's feed_queue_track.

The consequence reached the user as a broken NPB AudioStamp: the track edge
seeds quality_state from those same numbers, so starting playback from a
playlist, an artist page, a label page or a Library-feed row left TRACK_MAX at
zero and the badge could not render a complete bit-depth / sample-rate line.
Starting from an album was perfect, which is what made it look intermittent —
the album path was one of the five that had always built from the raw Track.
The rows now carry the numbers alongside the strings, because the producer
derives the strings FROM those numbers and was discarding them; parsing
"24-bit / 96 kHz" back into integers would have been a second source of truth.
The same builders were dropping artwork_url, which is why the queue sidebar
had no covers for playlist-sourced or local rows.

Also fixed here, found while tracing: local_playlist_qt::play shipped
context: None, so set_queue_stamped fell through to derive_context, which
answers None for a many-album playlist. The song card then used its per-track
album fallback and pointed at a local album_group_key instead of the playlist.
Pre-existing for the header Play, but a guard added last round routed the row
click through the same path, which turned it into a regression there.

SHUFFLE. set_shuffle(true) only randomises what comes NEXT, so four entry
points raised the mode and then played the list's #1 track: the album header,
the playlist header, the local-playlist header and every local play_rows call.
The one track the user actually hears on pressing Shuffle was deterministic.
All four now mix their own list with the xorshift Fisher-Yates the port
already uses in play_track_list_in and start at the top of the mixed order.
The mode stays raised so playback continues shuffled past the list. The owner
traced the flag-and-play-from-the-top shape to a very early Tauri experiment;
it is not the behaviour anywhere.

Not addressed, going to a separate pass: a Qobuz playlist carrying local or
Plex sidecar rows. The reference routes those through the local snapshot
(playback.rs:3488 is_mixed); this port has no sidecar concept on playlist_qt
at all, so such a row is still typed as qobuz. Same defect class, one layer up.

cc
2026-08-01 21:36:07 -06:00
vicrodh 35508b5f87 fix(qt): the defects found smoking the playlist and My QBZ round
Un-following a Qobuz playlist left it in the sidebar and in My Library.
toggle_follow called unsubscribe_playlist and settled the ownership snapshot
and stopped there, so the row only left at the next full refetch. Both
surfaces are patched directly now, in both directions: following from a card
buys the metadata with one get_playlist rather than re-reading
getUserPlaylists, which lags the write. The un-follow collapses a
hearted-and-followed playlist to the one row the next load will actually
produce instead of dropping it, because the local heart is a library.db flag
that survives the unsubscribe — removing every row made it vanish and come
back, which is the original complaint wearing the other shoe.

The Plex mark on album cards was a hard-drive glyph tinted accent — a blue
hard drive — and the three Qobuz kinds drew no badge at all. The badge now
ports discover/AlbumCard.slint:326-354 exactly: 24x24 at radius 4 pinned
bottom-right with a 6px inset, plex-logo untinted at 16, qobuz-logo at 18
(22 for a download), the purchase arm gold on its amber chip, hard-drive
white at 14 for local only. The brand marks are plain Images: routing them
through QbzIcon floods every pixel with the tint and flattens a logo to a
silhouette. SourceIcon moves to controls/ since it is no longer local-only,
and every other surface drawing a hard-drive was audited against its .slint.

The local album detail loses the two nav chevrons above the art. They are a
Tauri-era leftover from before the app had global navigation.

My QBZ used a bespoke card in the grid. MyQbzDetailCard is deleted and the
standard cards/AlbumCard is mounted with its overlay. localMode bundled four
separate things — action routing, the heart, the pin and Block this album —
so a multi-source container had to switch all four off together; it is split,
and the catalog affordances now follow the ITEM. On a Qobuz cell the heart
works through the shared favourite seam; on a track, playlist, local or Plex
cell it is absent rather than dead. Row covers carry two rungs: the list keeps
_50, the grid gets _600. Not the _230 that would merely cover a 200px card —
the complaint was that the cover reads softer than a normal AlbumCard, and a
normal AlbumCard is fed image.large, which IS _600. _200 is not a rung the
CDN serves at all (it answers 404).

The My QBZ grid sets reuseItems, and AlbumCard assigns to its own isFavorite
and isPinned when toggled, which destroys the binding that fed them. A
recycled delegate therefore showed the previous row's heart and the click
added the new album while the glyph said remove. Restored with the
Qt.binding idiom this port already uses on TrackCard, PlaylistCard and
TrackRow.

The details view no longer force-opens every row on entry, and the per-row
open state persists.

Library > Artists showed placeholders for nearly every portrait. The side
panel never reported its artwork window — the one caller of
libraryArtworkWindow is LibraryView.reportWindow, and the panel had no
report call at all, so no key ever reached the resolver and artMap stayed
empty for its rows. The rail now reports through that same path, with the
section-header sentinel reportWindow already skips. Note the reference has
no portrait fallback either: an artist Qobuz serves no image for is a
placeholder on both sides.

cc
2026-08-01 15:29:03 -06:00
vicrodh a16cf56c86 feat(qt): playlist manager, folders, importer and the Library parity round
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
2026-07-31 22:10:19 -06:00
vicrodh d1862e90da fix(qt): the Source column draws the real Qobuz and Plex marks
Owner: "si es de qobuz, icono de qobuz, si es de local (folder o plex) cada uno
tiene sus svg; Slint y Tauri los tenían bien."

They did. The port did not, and the reason was an asset gap rather than a
styling choice: `qobuz-logo-filled.svg` and `plex-logo.svg` were never carried
over from the Slint tree, so `SourceIcon` substituted `cloud-download` for every
Qobuz kind and a tinted `hard-drive` for Plex — drawing "downloaded" and "local
file" where the design calls for two brand marks. Both assets now live in
`qml/assets/brand/`, beside the Discogs / Last.fm / MusicBrainz marks that are
there for exactly the same reason.

The rule this restores is one `SourceGlyph.slint:4-8` calls load-bearing: the
full-colour logos render UNTINTED and only the monochrome local glyph is tinted,
to MUTED text and never accent. Slint spells out why — routing a logo through a
tinting primitive "would turn the logos into a flat accent silhouette, the bug
this primitive replaces". `QbzIcon` floods every pixel with its tint, so the two
logo arms deliberately do not go through it.

`qobuz_purchase` is the one arm that must tint a multi-colour asset (the same
mark in #eab308 — the colour is what says "purchased"), so it is the one arm
that uses an effect, and it degrades on a software renderer to the untinted mark
rather than to nothing.

The two ROW call sites now pass SourceGlyph's own numbers (15px, muted) instead
of inheriting the version picker's (14px, secondary) — the two Slint components
this file stands in for disagree on both, which is why they are properties.

Verified by driving the app: the Qobuz rows show the Qobuz mark and the Plex
rows the gold Plex chevron, in their own colours, with zero "Cannot open" in the
log.

cc
2026-07-31 01:33:02 -06:00
vicrodh 83c1aec4f5 feat(qt): collapsible rows in the My QBZ detail, and ephemeral leaves no trace
**The accordion the owner asked for.** Each expandable row gets a chevron;
clicking it opens THAT row's tracks inline and nothing else. Verified by driving
the real app: in list mode every row is collapsed with the chevron pointing
right, clicking row 3 — a PLEX row, so the hybrid path — flips its chevron,
renders its three tracks with durations and quality badges, and leaves rows 1,
2, 4 and 5 untouched.

This is a DELIBERATE divergence from both references, decided by the owner with
the facts in hand: neither Slint nor Tauri has a per-row accordion. Both render
inline tracks only as a view MODE, for every row at once, and both say "no
chevron" in their own comments (MixtapeDetailView.slint:1139,
MixtapeCollectionDetailView.svelte:124-127). The owner first believed Tauri had
it, asked for the code to be checked rather than the comment, and on the
evidence chose the accordion anyway. Do not "restore parity" by removing it.

Built additively on machinery that already existed: the document already carried
`canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`. What was added
is per-row OPEN state in Rust, a toggle invokable, and lazy per-row fetching —
opening a row fetches only its tracks, once. The "expanded" view mode survives
and now means "open all", sharing ONE notion of open rather than a second
parallel state.

Also here:
- `body`'s `anchors.bottomMargin: 100` shrank the list VIEWPORT; in the .slint
  that 100px is padding INSIDE the Flickable, i.e. scroll runway. Measured on
  screen before the fix: the last row clipped mid-height with ~85px of dead
  band under it.
- `apply_resolved` cloned and serialised the WHOLE document per resolved item
  (34 serialize→parse→patch cycles for one collection, 200 for a big one, on the
  GUI thread), and `patch_expanded` did the same while the document carried
  every loaded item's inline tracks — O(n²).
- The DEAD affordances the audit filed against the modals and the grids.

**Ephemeral honours its name** (owner rule): an ephemeral folder plays and
queues, and writes nothing durable. Recently Played and Most Played are now
guarded — an ephemeral album played through would previously have outranked real
albums in Most Played, one row per track start. The Slint reference does NOT
have this exemption; that is a gap in both frontends, not a Qt regression, and
this does not restore parity by removing it.

Two stores were deliberately NOT changed and need the owner's word: the
scrobblers and Discord presence. Neither is "las bibliotecas" — nothing is
stored on our side — but both publish the track to a third party, so if "ni now
playing ni nada" is meant literally they belong in the same decision. The
one-line fix location is in the lane report.

cc
2026-07-30 18:41:06 -06:00
vicrodh b6cf06ff2a feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db
opens instead of 23-34 — the local source owns a pooled handle and hands out no
guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an
await either. Bug 1 from the seam design is dead by construction, not by care.

The owner's rules, decided today, now live in one place instead of eight:
- **Collection** takes albums, singles and EPs from ANY source — qobuz, folders,
  Plex, and whatever is added next. **Mixtape** takes albums, playlists and
  tracks. This was a single boolean (`restrict_to_mixtape`) that could express
  neither release types nor "collections accept local".
- An unsupported combination must never be VISIBLE: the picker stops offering
  the impossible action, and the error stays a log-only safety net. Owner: "el
  bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI".
- **Ephemeral honours its name** — playable, queueable, and offerable to nothing
  else.
- A row's source comes from the ROW. Eight QML sites built the add-to-mixtape
  payload with `"source": "qobuz"` written as a literal, so a Plex or local
  track was stored as Qobuz and afterwards could not play at all: the app asked
  the Qobuz API for an id that never existed there.

`QobuzSource` reads the client through a lens on every call instead of holding a
clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384,
both on paths that run before `set_session`), so a cached clone goes stale and
fails silently — the hazard the crate's own doc comment described.

The lens is installed in `on_boot`, first of everything, and finding out why is
the lesson of this commit. The gate was green — build, 142 tests, both QML
audits, a clean offscreen smoke — and the feature was visibly broken: every
Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE"
type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever
touches it first BUILDS it, and one built without the lens leaves Qobuz
permanently detached. Only driving the real app surfaced it; the crate's error
string named the cause exactly, which is the one thing that made it a ten-minute
fix instead of an afternoon.

cc
2026-07-30 17:14:40 -06:00
vicrodh ea373a601c fix(qt): four AUDIBLE parity rows — stream errors, seek clamp, label shuffle, Plex keys, Library queue
PARITY-DEBT #7, #15, #17, #4 and #5.

- **#7** the stream error was drained and only logged, so an ALSA / exclusive
  failure — guaranteed inside Flatpak and Snap — froze the bar at 0:00 with no
  explanation. It now toasts, with the sandbox-aware rewrite the reference
  carries (playback.rs:5093-5115). This is the support issue that fix was
  written for.
- **#15** seek was clamped to the full duration instead of the downloaded
  fraction, so the head could be dragged into bytes that do not exist. The
  poll loop publishes `npSeekableMax` and both bars clamp to it, matching
  SeekBar.slint:93-98. The cache overlay was already drawn and purely
  decorative.
- **#17** a label page's Shuffle called `set_shuffle(true)`, turning the GLOBAL
  shuffle mode on and leaving it on for everything played afterwards. The
  reference shuffles the vector in place and never touches the mode.
- **#4** a `plex:<hash>` album-boundary key was passed where a Plex rating key
  belongs, so the fetch 404'd and the PREVIOUS track kept playing under the new
  card. Guarded at both sites, 1:1 with playback.rs:668-680 (`b5c1a76e`).
- **#5** clicking a track in Library played exactly one track and stopped. It
  now queues the whole VISIBLE list from the clicked row (`order_by_visible`).

The Library row play needed one edit outside its author's lane, which the
singleton xref caught before the build did: `QbzLibrary.libraryPlayVisible` was
called from QML and implemented in `library_qt`, but never declared on the
bridge. That is precisely the failure the xref exists for — QML resolves
singleton members lazily, so a missing invokable compiles clean and throws when
the user clicks. The QML had shipped a `typeof === "function"` feature-detect
with a fallback, so it degraded instead of throwing; the invokable now exists
and the fallback is dead code by design.

cc
2026-07-30 15:36:40 -06:00
vicrodh 1c2bdb749a fix(qt): the My QBZ detail delegates read the live row, not the model's snapshot
The whole in-place patch path was a silent no-op, and it took the Quality
column and expanded mode down with it.

`model: root.rows` is a JavaScript array. QQmlDelegateModel SNAPSHOTS such a
model, so a delegate's `modelData` is the engine's own copy of the element, not
the object sitting in `root.rows`. `syncRows` patches with `Object.assign` on
purpose — reassigning the array resets contentY and throws the user to the top
of a 200-item collection — and then bumps `patchRev` to re-evaluate the
bindings. Every part of that worked except the last one: the bindings
faithfully re-read the STALE copy.

Measured before the fix, driving the real app over VNC: 34 items resolved
Rust-side with correct tiers (26 get_album, all 200 OK), 34 publishes, 71 QML
refreshes, `syncRows` taking the patch branch every time — and the Quality
column sat on its loading dots forever, byte-identical five minutes apart.
Expanded mode rendered no inline tracks at all, for exactly the same reason:
`inlineTracks` is delivered by that same patch.

The fix is to index the live array: `patchRev` stays the dependency and
`root.rows[index]` is the object Rust actually patched. `modelData` remains the
fallback for the frame where the array and the delegate model are momentarily
out of step. No array reassignment, so the scroll position is still preserved.

After: the badges resolve to "CD 16-bit / 44.1 kHz" and the inline track lists
render with their durations and per-track badges.

cc
2026-07-30 15:19:46 -06:00
vicrodh 494596616e feat(qt): port the Blacklist manager for artists and albums
The port had a 67-line settings row showing two counters and a header saying
the Manage chevron was "NOT shipped — this port has no Blacklist Manager view
for it to open". The Slint has 1,114 lines of manager plus the consumer surfaces
that make the feature mean anything.

- src/artist_blacklist.rs is a 1:1 port of the reference's per-user lifecycle,
  including the fail-open asymmetry (is_enabled reads TRUE when there is no
  session, everything else false/empty) and the byte-identical NO_SESSION_ERR.
  src/reco_dismiss_qt.rs carries the reco-dismissal store. Both are bound in
  ALL THREE session-activation paths — the offline one included, because the
  reference records that Tauri never bound it offline and blacklisted artists
  leaked into offline surfaces — and torn down on logout, or the next account
  inherits them.
- The manager view: three tabs (bordered, ADR-008 — no pills), search, sort,
  the enable toggle, the disabled warning banner, the four body branches per
  tab in their load-bearing order, windowed lists, and the clear-all confirm.
  Reached from Settings only, exactly like the Slint; no sidebar entry.
- Every one of the ten store mutations publishes TWICE — its own document and
  publish_settings() — because the Settings counters live in a different
  document and nav_qt::back() re-runs no per-view load. Without it,
  Manage -> unblock -> Back showed pre-mutation counters. The Slint gets this
  free from a shared global.
- The search field is driven imperatively, not by a binding: QbzLineEdit's X
  used to destroy the binding, after which the box went blank while the list
  stayed filtered.
- Consumer surfaces, which is where the feature actually lives: the album card
  and album list row gained "Block this album", the artist card gained the
  3-arg "Not interested", the album header menu gained Block/Unblock swapping
  on state, and the artist page gained the Show/Blacklist flip plus the amber
  hidden banner with its "Show artist" button. album_qt and artist_qt now seed
  isAlbumBlocked / isBlacklisted at document build time — without the seed an
  already-blocked album offered "Block this album" and the first click
  UN-blocked it, the same lie the pin glyph told before isPinned existed.
- search_qt passed &Default::default() for both blacklist snapshots, so nothing
  was filtered anywhere and a greyed row still played. It now passes the real
  snapshots at both call sites.
- The 17 toasts the spec had dropped for want of a toast host are wired: that
  premise stopped being true when toast_qt landed, and a Clear All that failed
  with NO_SESSION_ERR was completely silent. Their msgids are in all eight
  catalogues; the five album-axis ones existed only in es and nl.

Also fixed in passing, pre-existing on this branch and unrelated to the
blacklist: ArtistView's library tab read `libTracks` unqualified from a child
of the object that declares it, which QML resolves against the component root,
not the enclosing object — so the "Tracks" heading threw a ReferenceError and
never rendered. Found by forcing the artist route in the offscreen smoke, which
no earlier gate did.

cc
2026-07-29 21:24:12 -06:00
vicrodh acf23abc9b feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
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
2026-07-29 21:23:45 -06:00
vicrodh d4667f5ea9 perf(qt): rounded covers use a real mask, the spectrum ticks at 62Hz, the derivative cache is bounded
The track's "ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the
software path this port runs on" doctrine was measured FALSE on 2026-07-29:
qbz-qt runs on the GPU (OpenGL RHI, threaded render loop, vsync 5.56ms). The
claim came from an offscreen session, and QT_QPA_PLATFORM=offscreen forces
software by definition. Every design it justified needed re-deciding.

- RoundedImage was a Canvas (renderTarget: Canvas.Image) that rasterised with
  QPainter on the CPU AND uploaded a texture per repaint, for every cover in
  the app, purely to round a corner identical in every frame of that image's
  life. It is now layer.enabled + MultiEffect { maskSource } — an FBO rendered
  once and cached for static content. Rectangle { radius; clip: true } cannot
  do this: clip in Qt Quick is a RECTANGULAR scissor and ignores radius.
  maskThresholdMin/maskSpreadAtMin are 0.5/1.0, NOT the 0.0/0.0 that looks
  natural: 0.0 collapses Qt's mask smoothstep to step(0, alpha) and every
  pixel with any alpha snaps fully opaque — measured 0 antialiased pixels at
  radius 40 against the Canvas's 292. At 0.5/1.0 it is 308, ramp [0,51,219,255].
  A software machine keeps the Canvas arm (GraphicsInfo.api), and
  QBZ_QT_ROUND_MODE=canvas pins it on a GPU machine where the mask misbehaves.
- VizSettle drove the spectrum from FrameAnimation, i.e. every rendered frame:
  180fps on this box for data the producer publishes at TARGET_FPS = 30. Now a
  16ms Timer; the wall-clock maths is unchanged.
- SpectrumBand's per-bar y was a SECOND JS binding derived from height. An
  anchor does the same arithmetic in C++ with no binding at all, and each FFT
  frame is materialised into a plain array once per publish instead of being
  read element by element across the JS/C++ boundary. Bindings per tick: bars
  28 (was 56), waveform 48 (was 96), energy 5 (was 10).
  QQuickPaintedItem was investigated and rejected with evidence: cxx-qt 0.7.3
  emits X(QObject* parent) : base(parent), which does not compile against
  QQuickPaintedItem(QQuickItem*), paint() is pure virtual, and there is zero
  precedent for a custom base class in this workspace. Qt 6 ShaderEffect takes
  no GLSL, only precompiled .qsb, which would make the build depend on a Qt
  tool whose path differs on macOS.
- PlaylistCollage point-sampled its mosaic in its own Canvas
  (Context2D.drawImage does not filter when it scales — measured RMSE 0.000
  against nearest-neighbour). It now draws through RoundedImage.
- The scaled-derivative cache had no eviction at all and the .jpg -> .png key
  change had orphaned 1251 files. Boot housekeeping sweeps the orphans once and
  bounds the directory at 64 MB, LRU by mtime. Verified by effect: first run
  deleted 1259 files / 10.05 MB, every run since reports 0, and the directory
  is 14 MB. The eviction is only survivable because RoundedImage clears
  _scaled on Image.Error and re-requests.
- For You artwork patched the rails in place instead of republishing the
  document, which was resetting contentY. HomeView is destroyed on every
  navigation, so the patch map is re-handed on mount through
  QbzHome.refreshForyouArt() — otherwise the Radio rail and the Spotlight hero
  went blank for the rest of the session after one navigation, and the skeleton
  pulse Timer stayed pinned ON, defeating the fix it belonged to.
- Spotlight's placeholder gated on the local path instead of the remote url, so
  it flashed a glyph on a cold cache where the Slint does not.

The retired doctrine is corrected wherever it was load-bearing for a decision.
The pre-baked icon tints are deliberately NOT reopened and no drop-shadows are
added: both are visual changes, not perf fixes.

cc
2026-07-29 21:23:10 -06:00
vicrodh 0c159c141c feat(qt): For You gains Qobuz Mixes pages, Artist Spotlight and Radios
Ports the three sections the tab was missing, against the Slint
implementation rather than invented: the section rails, the cards, and the
mix landing page with its navigation seam so back/forward behave.

The mixes reuse the shared crates for their requests -- the Slint app
already makes them, so nothing new talks to Qobuz here.

Also carries the qt-run.sh portability work needed for the Mac mini:
`free`, `nproc`, `pgrep` and `timeout` all differ or are simply absent on
BSD/macOS. Two of those bit during the first Mac build. `pgrep` exits 1
when it matches nothing -- the normal case -- and under `pipefail` that
killed the script before a single line of output, which reads exactly like
a build that did nothing. And `timeout` does not exist on macOS at all, so
the smoke produced a one-line log over which the grep cheerfully reported
"0 QML complaints": a test that never ran, rendering as a pass. The smoke
now fails outright on a log that short.

cc
2026-07-29 16:26:11 -06:00
vicrodh a3b89107fc fix(qt): table headers line up, the mini rail is centred, tooltips have a background
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
2026-07-29 16:25:57 -06:00
vicrodh 6b5226acf9 fix(qt): covers are actually downscaled, and non-square art keeps its ratio
The owner's clue was the whole diagnosis: the same image looked fine at
220px in the hover preview and bad at 150px in a card. So not the source,
and not the URL variant either -- every Qt producer picks exactly what
Slint picks, `large` (600px) into a 200px cell, 3x headroom.

`artwork_qt` was producing the right derivative and `RoundedImage` was
drawing the untouched 600px original next to it. `Context2D.drawImage`
does not filter, so every card was a nearest-neighbour downscale: measured
on a card cropped out of the running binary, RMSE 0.000 against a
point-sampled reference. The preview looks right because it is a plain
Image with smooth+mipmap and never touches this path.

Three defects were latent behind that dead derivative: `thumbnail(w,h)`
does not preserve aspect (46 square covers on disk written as 32x28),
`to_rgb8` matted transparent logos onto black, and the file was JPEG q75 --
more damage than the resample it was meant to cure.

Qobuz playlist art is 800x380, and both the header and the card were
stretching it to fill a square. It now keeps its ratio, and the uncovered
area takes a gradient derived from the image itself, reusing the ambient
colour pipeline rather than starting a second one.

cc
2026-07-29 16:25:42 -06:00
vicrodh d5bf4398ac fix(qt): Library sub-tab switch no longer segfaults, and the pane keeps its corners
Three identical faults in dmesg over three days, all
`segfault at 8 ... libQt6QmlModels.so.6.11.1[420c5]`. `nm -DC` puts that
offset in `QQmlDelegateModel::model()`; the QVariant return means sret,
so %rsi is `this` and address 8 is a null pointer plus the member offset.
`QQuickItemView::model()` reads its d-pointer, checks a QPointer guard and
then calls straight through without testing the value.

The re-entry is `onContentYChanged`, not `onModelChanged` as first
assumed: `setModel()` emits it from inside the window where the old
delegate model is already gone. Switching to Labels re-derives the model
AND clamps contentY to a shorter list, which is the emit -- hence the
intermittency, since at contentY 0 nothing is emitted and nothing
crashes. Reproduced in plain qml6 with no app and no login, and bisected:
readback in onContentYChanged dies, in onModelChanged survives 401 swaps,
cached array survives everything.

The fix is to stop asking the view for its own model. A tree-wide audit
found `.model` is the only member that is unsafe this way in 6.11.1 --
count, contentHeight, indexAt and itemAtIndex all null-check -- and the
three LocalLibrary tabs already cached their arrays, which is why this
stayed a Library-only bug.

Also: `clip: true` is a RECTANGULAR scissor in Qt Quick and does not
follow `radius`, so the album/artist header band painted over the content
pane's rounded corners. Slint clips to the rounded shape on the same
element, which is why the identical structure is correct there. The mask
sits OUTSIDE the frame rather than at a high z, because ADR-009 pins
in-pane modals at z >= 3000 with no ceiling and a z fix would be a
standing arms race.

cc
2026-07-29 16:25:26 -06:00
vicrodh 81c43a114e fix(qt): glyph colours follow the theme, on light palettes and on accent fills
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
2026-07-29 10:30:43 -06:00
vicrodh c56fab3c4e fix(qbz-qt): covers were never actually resized, plus the adversarial parity round
**The scaled-cover fix from the last round produced nothing.** The seam ran —
462 requests in one offscreen smoke — and wrote zero files, so the owner was
right that nothing improved. The artwork cache stores files as `.img`, and
`image::open` picks its decoder from the EXTENSION, so every call failed
silently. Sniffing the content instead yields 101 derivatives on the same run.
The header atmosphere had the identical bug and had therefore never produced an
image either, which is why the flat fallback was all anyone ever saw.

Reported as fixed last round without checking that it ran. Verified by effect
this time, not by reading the diff.

The rest came through an analyse / refute / implement / verify pipeline, and the
adversarial passes earned their keep:

- The sidebar mini rail: icons sat left of centre because their slot kept its
  fixed 24px width while the rail collapsed, so the hit target was rectangular
  and off-axis. The rail narrows, rows are square, and the mini state gains the
  folder flyout and tooltips it never had. The reviewer caught a comment
  claiming the width change was a one-token revert when it is two — corrected
  rather than left to mislead the next reader.
- Playback context now travels from every producer, not the handful that
  happened to stamp it.
- Collapsible per-tab search in Library, and label artwork.
- Every View-all destination Home offers, plus the label page and its releases.
- Collapsing the sidebar drops a Large bar back to New, live, without
  overwriting the user's persisted preference.

cc
2026-07-29 00:06:34 -06:00
vicrodh 2a5b5ac16e feat(qbz-qt): theme-following icons, the real header atmosphere, sharp covers, playback context
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light
themes secondary, muted and accent were simply wrong, and accent was wrong on
any theme whose accent is not #4285f4. Slint recolours at render time, which
this path cannot do — so the tint is generated once per colour into the cache
and loaded through the same Image+svg path the bakes already use. No effect, no
shader, so it cannot hit the "renders nothing on software" failure.

The sweep found the part the diagnosis had missed: "primary" was two meanings
welded together — max-contrast-on-theme-surface, and literal white for glyphs on
hosts that are dark under every theme. Remapping it globally would have painted
dark glyphs on dark scrims in fourteen places. The vocabulary is split instead,
with "primary" left as an alias so nothing changes until it is migrated
deliberately.

**The album header gradient was Slint's fallback, not its design.** Slint paints
a blurred artwork field and only falls back to the flat two-band gradient when
that image is unavailable; the port shipped the fallback. The field needs no
shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down
to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look,
not an optimization; everything after it works from eight pixels of colour,
which is why it reads as atmosphere rather than as a blurred cover. It comes out
of the same decode that already produced the flat tint.

**Covers were point-sampled.** Canvas drawImage does not filter when it scales,
measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in
a 200px cell throws away eight of every nine pixels, and the error is aliasing,
not softness. Rust now produces a derivative at the drawn device size so the
draw is 1:1 and there is no resample at all.

**The now-playing context stack.** It had no Qt counterpart: the model carried
context_kind and context_id, some builders stamped them, and nothing ever read
them back. The album path only appeared to work by coincidence, because the
glyph was hardcoded to open the album.

Plus: Discover's missing rails with real play history, Recommendations on the
integrations, queue drag reorder, and the playlist detail collage.

cc
2026-07-28 21:12:31 -06:00
vicrodh a1c771c39e fix(qbz-qt): row buttons, cover triggers, light-theme contrast, flyouts, genre filter
**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
2026-07-28 19:55:33 -06:00
vicrodh 9ab3d7db96 fix(qbz-qt): local albums route to the local view, and the detail-page parity round
**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
2026-07-28 19:01:25 -06:00
vicrodh f3a33245b2 feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**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
2026-07-28 18:12:31 -06:00
vicrodh d76f694781 feat(qbz-qt): close the two declared debts on the album and artist pages
The Last.fm similar-albums row exists now. The recommendation building was
already frontend-agnostic in qbz-external-reco, so this only adapts QbzCore to
its catalog trait and publishes the result; the row seeds on the album's artist
and excludes what the two Qobuz rows already show, including ids the
pre-resolution dedup misses. Resolved rows cache per album for thirty days in
the same store the Discover tab uses, so re-opening an album makes no Last.fm
or Qobuz calls, and only a non-empty result is cached — a transient failure
must not hide the row until restart. With Last.fm not connected it resolves to
nothing and makes no network call, so the rail simply never appears.

Relationship rows navigate. They carry a name rather than a catalog id, so the
click resolves through MusicBrainz first and only a CONFIRMED match opens an
artist — landing the user on a same-name musician is worse than a row that does
nothing. The role travels with the name so a match in another role is not
mistaken for this one.

cc
2026-07-28 16:45:45 -06:00
vicrodh 960715c4ac feat(qbz-qt): skeleton placeholders for the heavy views
Library and Home showed grey squares that filled in unevenly, which reads as
broken rather than as loading. One shared QbzSkeleton — a card shape and a row
shape — now stands in while data is on its way, and each placeholder clears as
its own item arrives rather than all at once, so loading reads as progressive.

The animation is the part that could have undone the point of this track, so it
is capped: a bounded number of placeholders breathe, and the timer stops when
the view is not visible or the window is minimized. Not on focus loss — a
tiling desktop keeps windows visible and unfocused, which is the rule already
established for the ambient field.

cc
2026-07-28 16:23:49 -06:00
vicrodh 49ab5f5b9d feat(qbz-qt): fill the Album and Artist pages from the integrations
The two pages were missing most of what Slint shows. With MusicBrainz, Last.fm
and ListenBrainz wired this round, the sections that depend on them can exist:
the artist biography and network, relationship rows, listener figures and the
credit blocks the Slint pages carry.

Nothing is faked. A section whose data an integration cannot supply is absent
rather than an empty frame, and a section fed by an integration the user has
not connected degrades to absent without a network call — integrations stay
strictly opt-in. The relationship rows are non-navigable for now because
resolving a musician name to a catalog artist needs a bridge invokable that is
not there yet; an informational row is better than a dead click.

cc
2026-07-28 16:23:36 -06:00
vicrodh e0fcf7a089 fix(qbz-qt): local context menus, collapsing search fields, toolbar density
Four things the owner hit in Local Library.

The context menu did nothing there — local rows and cards now mount the shared
menu the rest of the app uses, with the action set Slint gives local and Plex
items. Anything with no seam in this port (add-to-playlist, add-to-mixtape, the
tag editor, local favourites) is hidden or visibly disabled rather than a row
that silently does nothing.

The per-view search fields did not open and collapse the way the header's does.
That behaviour moved into the shared QbzLineEdit, so the Local toolbar, the
queue and the tree rail all get it from one place instead of four copies. The
queue's field also escaped its 300px column; it is width-bound now.

Filter, sorting and grouping become icon-only buttons with an explanatory
tooltip, which is what the owner asked for and what the crowding called for.

Found on the way, and affecting Qobuz cards too: the album card's ⋯ menu had no
"Play later" at all, and its "Add to queue" row dispatched play-later — the row
lied about what it did. Both fixed, with Slint's icon set for the three.

cc
2026-07-28 16:23:23 -06:00
vicrodh 31b1484c01 feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line
file. Slint's is 2,671 lines across five tabs, and the local album detail is a
routed page of its own. This closes that gap and splits the result: the view
becomes a composition root over qml/views/local/ modules, and
local_library_qt.rs becomes a narrow facade over local_* modules by concern.

Plex is wired for real. The port hardcoded the cache path to None, so the union
was local-only and Plex thumbnails were skipped. The settings store binds per
user at session activation, the album and track union includes Plex, thumbnails
resolve through the artwork path, rows carry their source badge, and the manual
re-sync is on the toolbar behind the availability gate — as in Slint.

Tabs: Albums with its quality/format/source filter popup, Tracks paginated,
Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail.
Every list is windowed from the start, artwork id-keyed and evicted, because
16k tracks is exactly where the Slint build freezes.

The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral
folder" pane plays music that was never indexed, and the album page gets its
version picker — a local album with two physical copies now switches between
them without a database round-trip.

Two agents built the QML and the Rust halves in parallel and the QML called
thirty QbzLocal members that did not exist. QML resolves lazily, so each would
have been a control that renders and does nothing; a static cross-reference
caught them before a build ever ran, and they are implemented here.

Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and
the tag editor have no picker or modal in this port yet, and local favourites
are not wired, so those actions log rather than route a local id into the Qobuz
API. Plex playback downloads the part instead of range-streaming it, which is
Slint's own documented fallback.

cc
2026-07-28 15:25:00 -06:00
vicrodh ade156a72a refactor(qbz-qt): split QbzAlbum and QbzArtist out of the God-object
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
2026-07-28 15:24:38 -06:00
vicrodh 5c631000b7 refactor(qbz-qt): split QbzLibrary out of the God-object
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
2026-07-28 14:07:40 -06:00
vicrodh 0799d59e01 feat(qbz-qt): Local Library — folder tree, windowed album grid, detail
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
2026-07-28 13:49:15 -06:00
vicrodh 10461bb49c fix(qbz-qt): playlist artwork — own graphic for Qobuz, collage for the rest
Two bugs with one root: the card bound the playlist's `images*` lists, which
are MEMBER-ALBUM covers, not the playlist's own artwork. So a Qobuz editorial
playlist showed one of its albums' sleeves, and a user playlist — which has no
`images*` at all — showed nothing.

Qobuz playlists carry their graphic in `image_rectangle`, which the model did
not deserialize; the field is added (additive, `serde(default)`, and Playlist
is only ever built through serde) and the card prefers it, contain-fitted so
the artwork is not cropped. Playlists without one fall back to the member-cover
mosaic, reusing the collage the sidebar already draws rather than a second
implementation. Both the All-tab grid and its list mode are covered — the 44px
list thumb binds the same map and would otherwise have gone blank.

cc
2026-07-28 13:48:05 -06:00
vicrodh aabde3be63 fix(qbz-qt): unresolvable QML imports, focus-based freezing, and dock render cost
Three views instantiated components the phase-23 directory move had left
unimportable: PlaylistView and SearchView use TrackRow (now in rows/) and
SectionRail uses AlbumCard (now in cards/). QML resolves lazily, so these
survived cargo check, the test suite and the offscreen smoke — they only
failed when the user opened a playlist. Adds a repo-wide audit of every
instantiated component against the importing file's visible directories; the
three above were all of them.

The ambient field and the new dock band froze on lost focus. That is wrong on
any tiling or mosaic desktop, where windows stay fully visible while
unfocused — the field would stop while the user is looking at it. Both now
gate on the window not being minimized or hidden, which is the real "nobody
can see this". Freezing on not-visible stays.

Dock band render cost, in descending order of impact: the drain publishes
only the stream the active mode consumes (Bars mode was marshalling a
512-float waveform QList 30 times a second for nothing); each mode marshals
its QList into JS once per tick instead of once per delegate; one shared
Gradient replaces 28 per-delegate instances; and the per-bar Behaviors are
gone since qbz-audio already smooths and clamps the amplitudes upstream.

18/18 tests; offscreen smoke clean; component-resolution audit clean.

cc
2026-07-28 12:26:40 -06:00
vicrodh 9a4fec6b70 refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges
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
2026-07-28 11:08:20 -06:00