Commit Graph
6330 Commits
Author SHA1 Message Date
vicrodh 0d6e7d3498 feat(qt): QbzMini bridge, mini queue projection and mini prefs
Block B1 of the miniplayer/tray contract. No UI: this lands the state the
five surfaces will bind to, so B2 can mount a window against a bridge that
is already correct.

src/mini_bridge.rs (new) — the QbzMini singleton. Six properties (open,
surface, backgroundBlur, miniQueueJson, miniWidth, miniHeight), boot(), and
the two custom-WRITE funnels. `open` mirrors into a static AtomicBool with
is_open() beside it, because the hotkeys pipeline runs on another singleton
and cannot read this QObject's properties.

src/mini_qt.rs (new) — the plain projection module: mini_queue_doc (current
track first, then the FULL unfiltered upcoming, no page cap, no art field),
the row-to-upcoming-index mapping, the geometry guards, and the pref seeds.
Five unit tests, three of them the contract's UT-1/UT-2/UT-3.

src/queue_qt.rs — display_title and fmt_duration widened to pub(crate) so
mini_qt reuses them instead of growing a third private copy; new
publish_mini_queue with its own identical-document guard, published from THE
queue funnel beside publish_coverflow. No second queue fetch.

src/settings_qt.rs — pref_f32, the reader with a default that the two
geometry keys need. Also reattaches three doc lines that sat on
read_pref_f32 while describing save_pref, which had none.

src/main.rs, build.rs, qml/Main.qml — the registrations: two mod lines
(mini_qt is a plain module and is deliberately absent from rust_files), the
bridge in rust_files, and QbzMini.boot() after QbzSession.boot().

Two deviations worth naming. Contract row A-9 asks for an invokable
set_surface, but row A-3 fixes a property named surface whose generated
setter is also set_surface — one QObject cannot carry both, so the funnel
sits on the property. QML assigns `QbzMini.surface = n`; there is no
setSurface member. And the surface write persists and re-kicks the queue
refresh unconditionally, 1:1 with the reference, suppressing only the
change notification when nothing moved.

cc
2026-08-04 02:28:57 -06:00
vicrodh 00eaa00027 docs(qt): the ambient POC-NOTE in AppShell was a fossil that lied
AmbientField is mounted at :46 and ambientOn derives from QbzShell.ambientMode,
so the note claiming the artwork-derived background is unimplemented had been
false for some time. Corrected in passing because this port's diff touches the
file -- LESSONS-2026-08-03 L10 asks for exactly that rather than propagating it.

cc
2026-08-04 00:35:02 -06:00
vicrodh 28d66352cb fix(qt): four defects the independent deferral hunt found
An agent that did not write this code audited it against the contract's §9,
§7 and §11.3. Four of its five findings are fixed here (the fifth, the sync()
ordering, landed in the previous commit).

1. QBZ_KIOSK_FULLSCREEN was INERT and the log LIED about it. fullscreen_at_boot()
   had exactly one caller -- choosing a log string -- so an appliance boot with
   the env set stayed windowed while printing "-> fullscreen-at-boot". The
   reference does the real thing at qbz/src/main.rs:8611. Now a seeded-at-
   construction QbzShell.kioskFullscreenBoot drives Main.qml's `visibility`
   declaratively, so it is part of the FIRST mapped frame. It is one-shot by
   construction and requires BOTH the profile and the env, which is what keeps
   a desktop user who toggles kiosk and restarts from being trapped in a shell
   with no titlebar control (incident 2026-07-11).

2. Shift+I was a reachable DEAD STATE. The rebindable ui.focusMode action
   reaches the hotkeys dispatcher from the kiosk root like any other non-nav
   key, so QbzImmersive.open went true over the sanctioned EMPTY mount -- and
   the kiosk root then rejects every arrow, Enter and Esc while it is true, so
   the whole zone model froze against a surface that renders nothing. The
   visualizer tile already declined to open it for this exact reason; the
   guard now sits in apply_open, the funnel every entry passes through,
   because the tile was not the only door. It refuses to OPEN, never to close.

3. Kiosk chrome on the small bar was half-ported. The reference HIDES the
   Lyrics and Queue buttons in kiosk (PlayerBarSmall.slint:846, :983); the
   port gated only their click handlers, so both still rendered and still
   toggled side panels the kiosk shell never mounts -- inert affordances,
   which §9.2 forbids.

4. "Kiosk mode" and "Desktop mode" shipped untranslated. My check_msgids.py
   compared against the 31 @tr strings in the Slint KIOSK sources, which do
   not include the two strings this port adds to the view-mode menu -- the
   checker was right and its input was incomplete. Both now in all 7
   translated locales.

Verified by driving, in one frame: the small bar's right cluster no longer
carries the Lyrics and Queue glyphs, and after Shift+I two Rights still move
the shelf cursor (scratchpad/kiosk/f-02-after-shiftI.png). The fullscreen arm
is verified only as far as a headless run can go -- both env branches log
correctly and the binding resolves -- since actual fullscreen needs a real
display, which is Pi/owner-smoke territory.

cc
2026-08-04 00:25:49 -06:00
vicrodh bf74e77f6b fix(qt): the shelf pulse could latch and go dead; re-arm it
Two defects in one seam, both found by an adversarial reviewer reading the
diff of the previous commit -- neither has a test that could have caught it,
and both are silent.

1. That commit moved set_nav_active BELOW set_hmove. The first arrow key of a
   session sets both in one model call, so hmoveChanged fired while
   nav_active was still false and no shelf consumed the pulse. nav_active is
   CONTEXT a handler reads, not something consumers have a handler on; hmove
   is the third consumer-facing field, alongside index and activate_seq. The
   ordering comment undercounted them. Fixed.

2. The deeper one, which predates that commit: hmove is cleared only by a
   consumer calling takeHmove(). If nobody consumed the last pulse -- no shelf
   focused, the ring not live, a skipped shelf -- the model still holds it, so
   the next Right writes 1 over 1 and cxx-qt's generated setter is
   CHANGE-GATED: it returns without emitting. The key is then dead until the
   user happens to press the other direction. hmove's value is not its
   meaning; the edge is. nav_left/nav_right now force the property to 0 first,
   so there is a real 0 -> +/-1 transition every time regardless of what the
   consumers did.

Verified by effect, not by reading the diff: four consecutive Rights on a
Discover shelf move the cursor four cards
(scratchpad/kiosk/s-02-four-rights.png).

cc
2026-08-04 00:09:01 -06:00
vicrodh e5f93d6805 fix(qt): the stale-derived-read hazard, swept across the kiosk views
KioskArtist's TypeError was found by luck, not by a method, so an independent
agent swept every on<X>Changed handler in the kiosk tree for the same class:
a handler reading a property that is DERIVED from the very thing it fired for.
Three more, all silent:

- KioskDiscover.publishNav() read `navSectionCount` from inside
  onActiveTabChanged, so it published the PREVIOUS tab's section count (and
  NaN on the creation pass). publish() clamps `index` into the count it is
  handed, so a wrong count is a focus ring parked on nothing, not a cosmetic
  slip.
- KioskMyQBZ.publishNav() had the same shape against `cards` and
  onOnCollectionsChanged, counting the previous route's document.
- KioskArtist's shelf skip-cascade read QbzKioskNav.dir inside onIndexChanged.

That last one was MY defect, not the view's: sync() wrote `index` before
`dir`, and each cxx-qt setter emits its NOTIFY synchronously, so the handler
ran while `dir` still held the previous move. The first Down of a session read
dir == 0 and refused to skip, parking the ring on a shelf of zero height; a
direction reversal skipped back the way it came.

Fixed at the source: sync() now writes every CONTEXT field (zone, columns,
count, tabs, shelves, dir, hmove, nav_active) before the two fields consumers
actually react to (index, activate_seq). The QML keeps its Qt.callLater as
well -- that covers Qt's own binding re-evaluation order, which is a different
hazard from the setter order. Two independent guards, because this class is
invisible to every static check.

The general remedy, in all four sites: compute from the source value at call
time, never read a cached derived property inside its own handler. Written up
at qml/Main.qml:233-247 from the window-geometry work.

Gate: 9 warnings = baseline, 291 tests, both audits OK, qmllint clean on every
touched file, kiosk-profile smoke clean, and the shelf skip cascade re-driven
over VNC (scratchpad/kiosk/s-01-skip-cascade.png).

cc
2026-08-03 23:59:42 -06:00
vicrodh ea26d743ab fix(qt): KioskArtist threw on every mount and skipped the first cover dispatch
Found by DRIVING the app, not by the gate: opening an artist logged
"TypeError: Cannot read property 'length' of undefined" at KioskArtist.qml:106
on every mount. The view still rendered, which is why nothing else caught it.

collectArtwork() read root.sections -- a readonly property derived from
`artist` -- from inside onArtistChanged, which is connected to the very notify
that dirties it. The handler runs BEFORE the dependent binding re-evaluates,
so the read saw undefined. Main.qml:233-247 already documents this hazard and
its remedy from the window-geometry work: compute from the value at call time,
never read a cached derived property inside its own handler.

Cost of the bug: the first artwork dispatch for an artist page was skipped
entirely, so covers only arrived if a later pass happened to re-request them.

Verified BY EFFECT -- rebuilt, re-drove to the artist page over VNC, and the
run log is now clean -- not by re-reading the diff.

cc
2026-08-03 23:43:01 -06:00
vicrodh 63e36c7543 feat(qt): the kiosk shell, its NavRail, the 4 card primitives and the 8 views
Contract sections 4, 5, 6 and the QML half of 7.

ContentRouter.qml is the D3 extraction: ONE view-mount chain, shared by both
shells. The Slint kiosk's content block is a verbatim copy of AppShell's and
KioskShell.slint:10-17 names this extraction as the follow-up it was waiting
for. 23 desktop arms carried over with their comments, 8 kiosk overrides, and
the kiosk-only nowplaying route. AppShell's four viewLoader.item sites now go
through contentRouter.currentItem; a tree-wide grep confirmed no other
reference (LESSONS L4 -- grep, do not trust memory about "both places").

KioskShell.qml carries the back bar with the full search focus lifecycle, the
content-height arithmetic including the now-playing exception, the offline
gate, the forced small transport, the player-zone focus ring, the root key
handler and the 30ms initial focus grab. Two findings worth keeping:

- the NavRail tiles fire header-menu-navigate("favorites-tracks") in the
  reference, which switches the view AND selects a landing tab; navigateTo
  carries only the route, so the tiles reuse the port's existing pending-tab
  handshake rather than landing on the wrong tab.
- the visualizer tile's action is a sanctioned no-op (R2). Wiring
  QbzImmersive would be worse than nothing: `open` would go true over an
  empty mount and the key guard would then reject every key against a
  surface that renders nothing.

Three fractional font sizes had to be rounded. Qt's font.pixelSize is an INT
property, so the reference's 11.5px (NavRail.slint:47) and 12.5px
(KioskNowPlaying.slint:59,67) are not expressible; font.pointSize is the only
real-valued alternative and is DPI-relative, which would make those three
labels scale differently from every other in the tree.

The NavRail one was caught by the offscreen smoke run IN THE KIOSK PROFILE --
it made the whole rail unavailable, i.e. a kiosk with no footer. The build,
both QML audits and qmllint were all green while that was true, and the
desktop smoke was clean because it never mounts KioskShell. The other two
were still latent: KioskNowPlaying is not on the smoke's route.

Gate: build 9 warnings = baseline, 291 tests, both audits OK over 229 QML
files (+15), qmllint filtered = the same 20 pre-existing ArtistView lines and
nothing else, kiosk-profile offscreen smoke clean.

cc
2026-08-03 23:23:24 -06:00
vicrodh 221f8704d5 feat(qt): kiosk profile — resolution, boot and the live toggle
Port of contract section 8. Three Slint blocks, faithfully:

- main.rs:7855-7876 kiosk_profile_active() / resolved_shell_screen()
- main.rs:8586-8618 kiosk entry (forced reduce-motion; fullscreen ONLY via
  QBZ_KIOSK_FULLSCREEN, windowed otherwise -- incident 2026-07-11: a user who
  toggles kiosk on the desktop and restarts would be trapped, since the kiosk
  shell has no titlebar control and neither Esc nor F11 leave fullscreen)
- main.rs:11767-11780 the live toggle, in the reference's order: persist,
  flip the flag, then swap the screen. The flag first means the shell that
  mounts next reads a correct kioskProfile on its FIRST frame.

"kiosk" is a SIBLING screen of "shell", not a mode inside it, which is how
app.slint:38 models it (AppScreen { splash, login, shell, kiosk }, sibling
mounts at :145 and :205). Both shells read the same bridges, so the toggle
tears nothing down and playback keeps running.

The toggle reads the CURRENT profile rather than re-resolving: resolving
would let a QBZ_PROFILE env override pin the reading and a user on a kiosk
image could never toggle out (the reference says so at :11764-11765).

ViewModeMenu's delegate called QbzImmersive.openFromMenu() unconditionally,
so flipping any row's `live` would have made it open Immersive. Each row now
carries its own action. Miniplayer stays the one inert row.

The small bar's view button becomes the toggle itself in kiosk
(PlayerBarSmall.slint:746-750): every other flyout row is gated
!kiosk-profile there, so the menu would carry a single row.

reduce_motion's kiosk half is now live -- shell_bridge.rs had the seam
pre-built with a `false` literal and a note saying one line, on their side.

Also: the gear icon. NavRail tile 7 uses settings.svg; this port only had
settings-2.svg, which is two sliders, a different glyph.

288 tests (4 new), 9 warnings = baseline, both QML audits OK.

cc
2026-08-03 22:32:10 -06:00
vicrodh 87c20fce8f feat(qt): kiosk zone-navigation model (QbzKioskNav)
The Qt counterpart of Slint's KioskNavState global (state.slint:3511-3536)
and the five movement functions on the kiosk shell (KioskShell.slint:95-200).

The state machine lives in kiosk_nav_qt.rs as a plain Rust model so cargo
test can reach it: the contract names the zone model as the piece a lazy
port silently degrades, and it is invisible to every static check the gate
runs -- no types to resolve, no bridge members to cross-reference, and a
click never sets nav_active, so the whole model can be dead while every
screenshot looks right. 22 tests cover the transition table.

A QML pragma Singleton was not available: the cxx-qt-build generated qmldir
cannot mark singletons (theme/QbzTheme.qml:12-13), and an object rooted in
the shell cannot be reached by views mounted inside a Loader without a
parent-chain walk.

Two departures from the Slint original, both deliberate:

- columns is clamped to >= 1. Slint's Math.mod(x, 0) is NaN and the
  comparison quietly fails; Rust's % panics, so a view publishing 0 would
  take the app down. No behavioural change for any legal value.
- Up from row 0 of a view with no tab strip goes to the rail. Slint runs
  min(tabs - 1, index - tabs), which for tabs == 0 is min(-1, 0) = -1; no
  item carries index -1, so the ring vanishes until a second Up. Live on
  KioskAlbum (:46) and KioskArtist (:24), both of which publish tabs = 0.

No boot() and no CxxQtThread: nothing in Rust ever publishes into this
object, so a hop would be a dead function that looks load-bearing.

cc
2026-08-03 22:15:28 -06:00
vicrodh 085fed81fb fix(qt): has_more on local sections counts matching groups only
Self-review catch on the commit before this one. In the album derivation the
match filter ran AFTER the total was bumped, so every group the filter
rejected still inflated the count and has_more came back true over a
destination with nothing extra to show - a "View more" link that lies. The
artist derivation already had the two in the right order; the two were
inconsistent with each other, which is how it slipped past.

Test covers the shape directly: one matching album among six rejected groups,
under a cap of 3, must report has_more false.

cc
2026-08-03 19:04:19 -06:00
vicrodh 422e5fd729 fix(qt): local sections match the query, and the ranking learns from the page
Three ranking/search defects, two of them found by the owner driving the app.

1. LOCAL sections showed groups that do not match the query.

Searching "Iro" listed Cynic and Die Toten Hosen under "Artists on Local
Library", and their records under "Albums on Local Library". Neither name nor
album title contains "iro". The cause: the SQL matches at TRACK level -
title OR artist OR album - and the album/artist sections are DERIVED by
grouping the matched tracks, so one track-title hit drags its whole album and
its artist into sections that are supposed to be about albums and artists.

The Qobuz sections beside them do not behave that way, so the local block was
lying about what it was showing. Derived rows are now kept only when the
group itself matches: album title or album artist for albums, name for
artists. The TRACKS section is untouched - a track matching on its title is
exactly what that section is for.

The reference has the same flaw; this is a deliberate divergence from it, on
the owner's report. Recorded as such so no future audit "restores parity" by
putting the noise back.

2. The results page taught the ranking nothing.

The port had exactly ONE record site, the cortinilla row click. The reference
has two - the second fires on any media action while the Search view is
current. Without it the case the whole feature exists for is broken:

  search "one metallica" -> right track first -> click. Learned.
  search "one"           -> Qobuz buries it, so it is not in the dropdown ->
                            "View more" -> click it on the page -> NOTHING is
                            learned, and the next "one" is just as bad.

record_page_interaction is gated exactly like the reference: Search view
current, module enabled, non-empty query, and the query is the PAGE's, which
is committed by definition. Hooked into the four crate-level media routers,
each self-gated so every other caller is unaffected.

3. Prefix attribution (contract S-N4, owner ruling R8 option ii).

A cortinilla click was learned against the PAYLOAD's query, which lags what
the user has typed: they keep typing while the previous payload is shown,
then click. That teaches a bucket named after a half-typed word - "annen"
learning from a click made while typing "annenmaykantereit". On the owner's
real store, 25 of 143 buckets are typing prefixes like that, and none will
ever be typed again as a whole query, so the score is simply lost.

The promotion is deliberately narrow: only when the payload's query is a
STRICT PREFIX of the live one. Anything else - box cleared, unrelated text,
a narrowing rather than an extension - is attributed as before, because a
broader rule would start moving interactions between unrelated buckets.

DECAY IS NOT IMPLEMENTED and that is the ruling, not an omission: the max
observed score on a real machine is 6 against a cap of 1000, so nothing is
saturating, and decay would add a tunable constant calibrated on nothing
while visibly reordering results.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit S-N4
plus two owner-reported defects.

Gate: build clean, 261/261 tests (3 new pinning the owner's exact "Iro" case).

cc
2026-08-03 19:01:09 -06:00
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 b38b0b3d57 feat(qt): instant cached cortinilla paint (default on, ui_prefs opt-out)
The reference shows a skeleton while loading and never paints cached rows
first, and its comments present that as the design. It is not: the instant
paint existed during development and was removed because it GLITCHED -
results visibly jumped between the cached paint and the fresh one. The
owner's reading is that the glitch was a Slint rendering problem rather than
something inherent to stale-while-revalidate, so it comes back here as a
reversible experiment: DEFAULT ON, with a ui_prefs opt-out
(cortinilla_instant_paint) that needs no rebuild to flip. The skeleton path
stays fully implemented as both the opt-out target and the cold-miss path.

What is cached is the FINISHED payload, not the API response. Reviving the
existing CAPA A result cache would have left the expensive half on the table:
a cortinilla payload is the response AFTER ranking, truncation, i18n titles,
the top-result pick and - the part no API cache can shortcut - the local
library query and its derived grouping.

qbz-app gains a generic PayloadCache<T>. The store never inspects T, which is
what lets both frontends share it despite their payload types having diverged
(one raw artwork_url vs an art_url/art_path pair). In-memory only: process
lifetime IS the TTL, because a payload embeds local rows and resolved artwork
paths and a persisted entry would resurrect albums the user has deleted.

Four properties are what should keep it from glitching, and each is a real
constraint rather than a hope:

  - Same shape in both paints. The entry is written AFTER the local sections
    are appended, so there is no mid-apply reflow left to see.
  - Equality-gated replacement. CortinillaData derives PartialEq, so an
    unchanged repeat query produces ZERO second paint.
  - The selection survives - the publish path never writes selectedIndex.
  - The scroll survives - C5's contentY latch. Without it the second paint
    yanks the user to the top, which is the glitch by another name.

There are exactly TWO write points, and the placement is load-bearing: after
pass-1 artwork resolution, and again at the pass-2 republish once downloaded
covers land. Writing earlier caches rows with empty art_path, and since the
QML draws art_path only, a hit would paint cover-less rows AND the gate would
fire on every hit - the exact repaint this exists to avoid.

Invalidation: bound per session, dropped on logout, and cleared from
reload_browse - the single chokepoint every local-library mutation passes
through, since there is no library-changed signal to subscribe to.

Also fixes the immersive skeleton's scope bug, which is the twin of the one
C4b fixed on the desktop and which I missed there: a Timer derives from
QtObject, which has no parent property, so the unqualified parent resolved
out of the block entirely - the timer ran forever, pulse never changed, and
the skeleton never pulsed. Found by qmllint, not by reading. It picks up the
reduceMotion tick with the fix.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C11
(section 5.c, rulings R1/R6, R7-10).

Gate: build clean, 258/258 tests + 2 new in qbz-app, both QML audits OK,
qmllint clean on both cortinillas, offscreen smoke 0 complaints + home
published.

cc
2026-08-03 18:09:04 -06:00
vicrodh 10f249be24 feat(qt): module-OFF results fallback and search service teardown
Two gaps that both made the port quietly lose a capability.

Turning Intelligent Search off is a SWAP in the reference, not a removal:
typing live-navigates to the full results page on a 300ms debounce instead
of opening a dropdown. Here it returned, so with the module off typing did
nothing at all until the user pressed Enter - the kill switch removed the
feature rather than exchanging it.

The per-user search service had no teardown on logout, so the next account
inherited the previous one's learned ranking: top_for_query would promote a
stranger's most-clicked result and the interaction store would keep
accumulating under their buckets. The reference tears it down for exactly
this reason. The version counter and both payload snapshots are cleared with
it, so a load still in flight at logout cannot publish into the next session.

auth_qt's asymmetry note is updated rather than left claiming a gap that is
now closed.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C10
(J5, J7, R7-5).

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

cc
2026-08-03 17:45:50 -06:00
vicrodh 06e8b4ba4d fix(qt): pass the cortinilla row's block padding in instead of reading parent
The row width read parent.leftPadding / parent.rightPadding. It works at
runtime - the parent is always a Column that has them - but `parent` is typed
QQuickItem, which does not, so qmllint cannot verify it and a wrong mount
would fail silently at runtime instead of loudly in the audit. Parent-chain
bindings are a documented trap in this port for exactly that reason.

The padding is now a property each mount site sets. Cortinilla.qml is
qmllint-clean.

Found by running qmllint over the files this branch touched, after two
identifiers were written before being verified against the source - a
Flickable forceLayout() that does not exist, caught by the smoke, and a
tracksQuery property that does not exist, caught by reading. Neither the
resolution audit nor the singleton xref can see this class of error: they
check component types and BRIDGE members, not view-local properties. qmllint
does, and it is now part of the gate.

cc
2026-08-03 17:42:04 -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 7e58f2f43c feat(qt): cortinilla local row click routing
Local rows route through the local seams, never the Qobuz ones. Their ids
live in a different space entirely: an album id is a group key, a track id is
a library row id, and an artist row has NO id at all. Handing any of them to
the catalog routes would 404 or, worse, open a stranger's album that happens
to share the number.

  - album  -> the local album view, by group key.
  - artist -> the LocalLibrary Artists tab BY NAME, because there is no id to
              route by.
  - track  -> played from the per-query snapshot.

Playing from the snapshot rather than re-resolving the id is deliberate: the
snapshot is the exact vector the payload was built from, so the queue matches
what the user is looking at - including the Plex rows that were prepended,
whose order a fresh lookup would not reproduce. The queue is the WHOLE
fetched vector starting at the clicked row, which is the reference's
behaviour: the dropdown shows three tracks, but activating one gives a real
queue instead of an orphan.

The position lookup is STRICT. If the clicked row is not in the snapshot it
logs and does nothing; falling back to index 0 would silently play a
different track than the one under the cursor, which is worse than not
responding.

Local rows stay out of the learned ranking, which the existing guard already
handled - it was dead code until now because no local rows existed. The
reason is the same id-space problem: a bucket keyed on a local id could never
be matched again, and a local artist has no id to key on.

Playlist rows are no longer inert. The note calling them impossible said
there was no playlist view in the port; open_playlist has existed and
PlaylistView.qml ships, so the row now opens the playlist like every other
surface does. PARITY-DEBT row 28 loses one of its sites.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C8
(sections 3.1-3.3, 3.6, R7-2).

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

cc
2026-08-03 17:23:31 -06:00
vicrodh 15610bc7e9 feat(qt): desktop cortinilla local sections
THE deliverable. The header dropdown returned Qobuz results only; the
reference also returns on-device results. It now does too.

live() runs the two halves CONCURRENTLY with tokio::join!: the Qobuz search
and the local-library query. Neither waits on the other, which is what makes
a slow or unreachable Qobuz still produce a usable dropdown. The local half
appends three sections LAST - Albums, Artists, Tracks - with Plex unioned in
and prepended, and the append re-runs assign_flat_indices so the local rows
get contiguous indices after the Qobuz ones.

Caps widen offline or on an unauthenticated session, from 3/2/3 to 8/4/8:
with no Qobuz half the dropdown IS the local library, so a compact block
would waste the panel. The session is read on the synchronous side, before
the debounce, so it describes the session at the keystroke.

A Qobuz failure now DEGRADES to a local-only payload instead of discarding
everything. The old Err arm returned, which is what made the whole dropdown
vanish on a network hiccup; the reference's loader has no ? and cannot
return Err at all.

LAST_CORT_LOCAL keeps the raw rows in lockstep with the payload. The click
router cannot re-resolve a track from a row id - a local artist row has no
id, and a local album row's id is a group key - so it indexes the snapshot.

Both offline early returns are gone. search_live returned before doing
anything when offline, justified by a note saying there was nothing to show;
that note was the reason this feature stayed invisible, and it is now false.
search_submit returned too, which left the PREVIOUS results page on screen,
so Enter offline looked like a dropped keystroke.

Also lands the Qt half of the cache deletion (S1b). store() persisted into
the Intelligent Search result cache, which NOTHING has ever read:
SearchService::cached has zero non-test callers in either frontend and
git log -S shows the read side was never wired in any commit. Every
keystroke paid a full serialize and fs::write of a multi-megabyte artist
store, on the calling thread, inside the mutex the UI thread contends for,
to feed a file nobody opens. Removing the writer cannot change any rendered
output. The Slint half is commit S1a on branch S; this one lands now so the
Qt binary stops paying it without waiting for a merge.

The immersive dropdown is UNCHANGED and stays deliberately different: albums
only, no tracks, no top result, no View more, and selecting a row acts on
playback rather than navigating. It shares only the local ALBUM mapper.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C7
(D1-D26, J1, J2, S1b, R7-1, R7-3).

Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published. AC-27: no store in search_qt.rs.

cc
2026-08-03 17:19:27 -06:00
vicrodh a7e64c0bdf feat(qt): search_local.rs - the local cortinilla mappers and loader
search_qt.rs already hosted three controllers and was well past the size
budget, and both dropdowns need these mappers. The immersive arm shipped
first with its own copies of LocalCaps, local_album_artist,
derive_local_album_rows and append_immersive_local_albums; those MOVED here
rather than being duplicated, and the immersive arm now calls the shared
ones. Pure and blocking helpers only - no Qt types, no bridge, no ui().

New, ported from the reference and not yet wired (C7 does that):

  - derive_local_artist_rows. Grouped by canonical album-artist,
    case-INSENSITIVELY, where the album grouping is case-SENSITIVE. That
    asymmetry is the reference's and is reproduced, not smoothed. id is left
    EMPTY on purpose: local artists have no id anywhere, so routing is by
    name - which is exactly why C5 had to stop hasTop testing id emptiness.
  - map_local_track_to_cort_row, with the "artist · album" subtitle and its
    fallbacks.
  - append_local_sections: Albums, Artists, Tracks appended LAST, section
    kinds local-album / local-artist / local so View more routes to the right
    LocalLibrary tab, while per-ROW kind stays album/artist/track so the
    thumbnail shape and the click router behave. Empty sections are skipped.
  - local_art_split, factored out of the album builder so all three row
    builders share one artwork rule.
  - load_cortinilla_local(query, limit, gated), re-scoped from load_imm_local
    with the intelligent-search gate the main cortinilla needs; the immersive
    arm passes false because it has its own enable.

Two fixes ride the re-scope. exclude_network_folders was a literal false and
is now the exact twin of the reference - connectivity confirmed Down, not
merely "offline", because a manual offline toggle with the LAN up must keep
NAS content visible. This fixes the cortinilla's call site only; four other
Qt call sites still pass false and PARITY-DEBT #82 stays open. And the path
now carries a [qbz-qt][perf] line: the port had none on any local path,
which is why the cost of this query on a large library was unmeasured.

The module header records the reference behaviours reproduced deliberately:
no dedupe between the Qobuz and local halves, window-relative has_more, Plex
prepended so it can starve the local-file tracks, and no folder-cover
fallback.

Three dead-code warnings are expected and are exactly the C7 seam:
derive_local_artist_rows, map_local_track_to_cort_row, append_local_sections.

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

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

cc
2026-08-03 17:14:20 -06:00
vicrodh 263b82a694 fix(qt): cortinilla robustness - scroll latch, hover, hasTop, index guard
Six defects, plus one owner-requested behaviour change.

  - The document is now applied imperatively instead of bound. cortinillaJson
    is republished wholesale when the artwork pass lands, about 1.5s after
    the rows, and a bound Repeater model made that a full model reset: every
    delegate destroyed and rebuilt, contentHeight momentarily 0, and
    StopAtBounds clamping contentY to 0. The user was yanked back to the top
    mid-read. The offset is latched and restored via Qt.callLater - NOT
    forceLayout(), which is a QQuickItemView method and does not exist on a
    plain Flickable; calling it threw a TypeError and clamped against a stale
    contentHeight.

  - panelHovered could latch true forever: if the panel went invisible while
    the pointer was over it, the hover source never reported false again.

  - Hover detection moved from a MouseArea to a HoverHandler. The MouseArea
    was declared first, so the Flickable and every row's MouseArea sat on top
    of it and took the hover events - containsMouse stayed false across most
    of the panel, which is why hovering did not hold the dropdown open. A
    plain MouseArea stays behind it purely to swallow background clicks.

  - hasTop tested the top row's id for emptiness. Local ARTIST rows carry an
    intentionally empty id - routing is by name - so the top-result block
    would have vanished for them once the local sections land. It now trusts
    the Option the payload already models.

  - The row click passed flatIndex raw across the i32 boundary, where
    undefined coerces to 0 - the top result's index - so a malformed row
    would activate the wrong thing rather than nothing. The highlight already
    guarded; the action now does too.

  - The nested Repeaters both used an unqualified modelData. It resolves
    correctly today, but one added `required` on the inner delegate would
    silently flip every row to render its section. Both are named now.

  - LAST_CORT's locks no longer unwrap(). row_clicked and move_selection are
    invokable bodies, i.e. Qt GUI thread, so a poisoned lock there would take
    the UI down over a snapshot that is stale at worst.

OWNER DIVERGENCE (2026-08-03, unplanned): hovering the panel no longer
pauses the idle timer indefinitely - it stretches it to 30s. The reference
pauses outright, which means a cursor left over the panel by accident holds
it open forever. 30s satisfies both halves of the owner's ask: reading is
never interrupted at 4.5s, and a forgotten cursor still lets it go.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C5
(D-BUG-6/9/10/11/12/13).

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

cc
2026-08-03 17:07:17 -06:00
vicrodh 4e1703c436 feat(qt): reduceMotion seam and a coarse skeleton tick
The owner ruled 3-C: build the seam here so the kiosk port consumes it
instead of having to invent it. The earlier plan was to wait for kiosk to
define it, but kiosk is not implemented - it is a contract - so "wait for
kiosk" meant waiting on a date that does not exist.

The shape is no longer a guess, which was the only objection to building it
early. Two independent sources fix it: the kiosk contract mandates "FORCED
reduce-motion while in kiosk", and the reference computes the flag as
kiosk_profile || !use_gpu_renderer.

QbzShell.reduceMotion is derived, never a preference - do not add a Settings
row for it. The existing play-indicator toggle is a preference and a
different thing; binding them would make "animate the play indicator" change
this dropdown's pulse rate.

Only the kiosk half is wired, and the omission is deliberate rather than
partial: the renderer tier has no honest source here. It is a QML-side fact,
this port runs on the GPU in every real session, and the one place a probe
would report software is the offscreen smoke, which forces it by definition.
Wiring it would turn reduce-motion on exactly where it means nothing. The
kiosk literal is a named const with a pointer to their contract; flipping it
is one line on their side.

The cortinilla skeleton is the ONE consumer: its single host Timer drops
from 700ms to 2000ms. No new timer, no per-delegate animation - the
reference's own note records that this pulse was the main cost of opening
the cortinilla on weak GPUs.

Giving that Column an id also fixes a real scope bug on the way past. The
Timer read an unqualified `parent`, and Timer derives from QtObject, which
has no parent property - so it resolved up the scope chain, out of the block
entirely. The sibling rectangles' parent.parent[.parent] chains are now
explicit too.

EqualizerBars.qml and the immersive contract's D11 both recorded that Qt had
no reduce-motion twin. Both are corrected: the seam exists, those surfaces
are simply not consumers yet, and wiring each is its own one-liner.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4b
(section 5.g G1-G5, ruling R10; D-BUG-1 folded in).

Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published. AC-39: exactly one behavioural consumer.

cc
2026-08-03 16:48:07 -06:00
vicrodh 033b838b98 fix(qt): cortinilla geometry - NPB-aware cap, scrim inset, row width, scroll base
Five geometry defects, all latent while the Qobuz-only panel held at most 14
rows and never scrolled, and all reachable the moment the local sections land.

  - The height cap hardcoded 42 for the now-playing bar in every mode. The
    bar is 42 only in NPB mode 2; everywhere else it is 112, so the panel
    could run up to 70px into it. The cap is now mode-aware, reading the same
    theme tokens AppShell uses. The header height literals in the scrims and
    the panel anchor became theme.headerHeight at the same time.

  - The click-outside scrims left a gap for the search field computed without
    the 60px the field gives up when the section nav sits in the header, so
    with nav-in-header there was a 30px dead strip on each side of the field
    that neither dismissed the panel nor reached the input. The scrim now
    tracks HeaderBar's own width rule exactly.

  - Rows were 12px wider than their block: a QML Column's padding does not
    size its children, and parent.width ignored it. clip hid the overflow,
    but the highlight ran flush to the right edge while staying inset 6px on
    the left.

  - flat_index_content_y started at 6.0, mirroring the panel's 6px top
    padding - which is a Flickable VIEWPORT margin, not content space. Every
    row was reported 6px low, so scroll-into-view over-scrolled going down
    and clipped the row's top going up. The immersive twin, written later
    against the same layout, already started at 0.0.

    Two unit tests now pin the arithmetic. There were none for the desktop
    arm, which is why the 6.0 survived; the immersive arm had its equivalent
    and was correct.

  - The scrollbar used the primitive's default 14px gutter where the
    reference overrides it to 10.

Plus the "Enter" affordance glyph (U+23CE + one space -> U+21B5 + two, as in
the reference) and the panel drop shadow, which reuses the immersive twin's
idiom - a mirror rectangle blurred by MultiEffect, skipped when there are no
shaders - rather than introducing a second shadow mechanism. NavFlyout still
has none and says so; that is its own parity pass.

AppShell's comment claiming the cortinilla is the "LAST child so it renders
above every surface" is corrected: four siblings are declared after it, and
that is fine.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4
(D-BUG-3/4/5/7, rows A5/A7/A10/A12/A13/A19/A28/A2, UT-1).

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

cc
2026-08-03 16:38:00 -06:00
vicrodh 5a194d2499 fix(qt): move the cortinilla debounce from QML into Rust
The 220ms debounce wrapped the WHOLE searchLive invokable in QML, so live()
- which opens the dropdown and raises the skeleton - only ran 220ms after
the LAST keystroke. Continuous typing therefore showed nothing at all. The
reference opens the panel and raises the skeleton on the FIRST keystroke of
2+ characters and debounces only the LOAD.

live() is now two halves. The synchronous half runs on the first poll of the
spawned task, i.e. at the keystroke: gates, selection reset, open, query,
version bump, loading flag. Then it sleeps 220ms and re-checks the version,
so a superseded task exits without touching the network. This is the shape
the immersive arm already uses (imm_live), which is the precedent in both
respects: raise synchronously, debounce the load.

cortinillaLoading gets exactly one owner in the synchronous half and is
written once more at the publish - never in both halves for the same
keystroke.

Two consequences ride along:

  - dismiss() becomes a real cancellation point. It only flipped the open
    flag, so an in-flight load survived Esc, published into a closed
    dropdown and rewrote LAST_CORT. It now bumps the version too. This
    matters more now that the version bumps per keystroke.
  - the idle-close timer restarts on the QUERY rather than the payload. A
    burst of typing publishes one payload but many queries, and the
    reference restarts per keystroke.

HeaderBar.qml's header claimed the cortinilla and live-search were "out of
scope" in the file that wires the whole input path. Corrected here, beside
the code that falsifies it, rather than in a later docs sweep.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C3
(H1/H2/H3/H8, D-BUG-8, R7-4).

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

cc
2026-08-03 16:23:06 -06:00
vicrodh dae6c3bb4f fix(qt): publish the live cortinilla query synchronously
Enter and "View more" submitted the PREVIOUS query. With the dropdown open
and nothing selected, HeaderBar calls clearSearch() - which wipes the input -
and then cortinillaSearchAll(), passing no text; search_all_action and
view_more then took the query from LAST_CORT, i.e. the last SUCCESSFULLY
LOADED payload. Type "beatl", let it land, keep typing "beatles", press
Enter while the fetch is in flight: the results page loads for "beatl".

The reference is immune because it publishes the query synchronously per
keystroke (SearchState.cortinilla-query, set before its debounce starts) and
reads that. This does the same: a cortinillaQuery property plus a Rust-side
CORT_QUERY mirror so the two async consumers can read it without a Qt round
trip, both written by one function so they cannot diverge - the discipline
CORT_OPEN already documents for cortinillaOpen.

Two more sites move onto the live query with it:

  - the overlay's visible gate now requires >= 2 characters, matching the
    controller's own gate, so a 1-char query cannot flash a half-empty panel;
  - the "No results for X" line quotes the live query instead of the
    payload's echo, so the empty state stops naming the previous search
    while the user is reading it.

One window stays open and closes in C3: the debounce still lives in QML, so
the query is published when the 220ms timer fires, not on the keystroke
itself. Enter pressed INSIDE that window still carries the previous text.
C3 moves the debounce into Rust, which is what makes the publish truly
per-keystroke.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C2
(D-BUG-2, rows B3/A3/E6/G9/G15).

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

cc
2026-08-03 16:13:02 -06:00
vicrodh 9b6c4d12ac chore(qt): delete the dead intelligent-search toggle
The toggle_intelligent_search chain was unreachable end to end: the bridge
invokable had zero QML callers, so crate::toggle_intelligent_search and
search_qt::toggle_intelligent_search below it could never run. Its comment
called it "the app-menu check", but no app menu exposes it in either
frontend - the Slint's only surface is AppearanceSettings.slint, and the Qt
equivalent reads its checked state from settingsJson and writes through the
settings_qt key dispatch, never through this.

The intelligentSearch property stays; it is the domain's canonical state.
Its comment is corrected to describe the path that actually exists.

No behaviour change: the deleted code had no reachable entry point. The
build still reports the same nine pre-existing warnings, which is the proof
the whole chain went rather than part of it. The torn-read guard the deleted
function documented is not lost - settings_qt::toggle_pref_bool still owns
it for every other toggle row.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C1
(DEAD-1).

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

cc
2026-08-03 15:34:54 -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 07b0ba818e feat(qt): keyboard shortcuts cheatsheet and customize editor (B3)
km
2026-08-03 14:29:49 -06:00
vicrodh 5130e872e7 feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km
2026-08-03 13:32:35 -06:00
vicrodh 0cca9abc58 feat(qt): hotkeys core, QbzHotkeys bridge, shortcut i18n catalogs (B1)
km
2026-08-03 11:33:14 -06:00
vicrodh c0f4d2f1b7 feat(qt): immersive player bar and search cortinilla (B5)
km
2026-08-03 09:33:02 -06:00
vicrodh 0b6e8ed7c9 feat(qt): immersive split view, lyrics, track info, suggestions, queue tabs (B4)
km
2026-08-03 08:07:24 -06:00
vicrodh 46c1c42408 feat(qt): immersive atmosphere and focus panels (B3)
km
2026-08-02 22:25:19 -06:00
vicrodh 8bc255269d feat(qt): immersive root overlay, header band, entry/exit chrome (B2)
Root overlay self-gated on QbzImmersive.open with the load-bearing
click-blocker, 6s auto-hide with the VNC hover-storm delta guard,
fullscreen guard, exit paths 2-4, seek arrows, the header band (view
menu in Slint order, search field on the inert B1 surface, window
controls with the first FullScreen write in the Qt frontend), the
ViewModeMenu Immersive row live, Shift+I in Main.qml with the
text-input gate, QbzLineEdit fieldActive seam (D16). Panels are stubs;
B3/B4/B5 replace them. RFB evidence in scratchpad/immersive-b2/.

km
2026-08-02 20:48:40 -06:00
vicrodh 3c721a448e fix(qt): openFromMenu must not persist over the remembered view triple
Slint's set_mode(0) fires no view-sig while ImmersiveView is unmounted,
and opened() restores the full triple verbatim. Routing the menu path
through setView persisted (0,0,0) over the remembered triple on a fresh
boot. Round-2 contract finding; 00-CONTRACT.md v3 §3.3.

km
2026-08-02 18:37:24 -06:00
vicrodh b5298b78bd feat(qt): immersive bridge, viz two-source enable, coverflow payload (B1)
km
2026-08-02 18:27:54 -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 5c2577c7ce fix(qt): header atmosphere, NPB button order, small-bar alignment, sidebar collage
The album/artist header band painted the 128x128 atmosphere as ONE Image at
anchors.fill + Image.Stretch, mapping the field 1:1 onto the band. That field
is vignetted in Rust, so a 1:1 map puts its bright core dead centre and the
vignette ring on the edges - the "plasta en medio" the owner reported.
ImmersiveAtmosphere.slint:21-67 lays FOUR copies, each larger than the band
(+520/+620/+700/+560 wide) and pulled back by a negative offset with
image-fit: cover, so what shows is a crop of a magnified field. Ported with
the opaque #0a0a0b base and the header's own base/warp opacities. The phase
constants are the .slint expressions with tick = 0 (animated: false), signs
included: copy 2 subtracts where copy 1 adds, and equalising them stacks the
four into one brighter copy.

PlayerBar mounted Cast before Connect; PlayerBar.slint has ConnectButton at
:401 and cast at :646. NowPlayingBarSmall already matched and is untouched.

Seven children of the small bar's right-cluster Row carried no vertical
anchor, and a QML Row top-aligns children that do not anchor themselves, so
the 32px buttons rendered at y=0 against the 3px seek line with all the slack
below. The queue button was among them.

The sidebar playlist micro-collage used radius + clip, and clip is a
rectangular scissor that ignores radius, so the cover tiles kept hard
corners. Rounded with the MultiEffect mask idiom PlaylistCollage.qml
documents, on the same _noShaders predicate so a software renderer degrades
to square corners rather than a blank tile. Its tile layout also diverged
from SidebarPlaylistCollage.slint, which draws ONE full-bleed cover below
four covers and only splits into a 2x2 at four, with a 1px seam.

cc
2026-08-02 16:32:03 -06:00
vicrodh 9ce7ef4ca0 perf(qt): draw the spectrum in one ShaderEffect per mode (macOS F3)
The 28/48/5 Rectangle delegates cost one dirty node + batch churn each
per frame on Qt's Metal path (QRhiMetal::setShaderResources per batch
is where the ~6x vs Linux lives). Each mode's Loader now instantiates
ONE ShaderEffect — one node, one draw call: heights go to scalar
uniforms (b0..bN) pushed in one JS pass per settle tick, bar/gap/
gradient geometry is computed in the fragment shader with the same
colors and 1px rounding as the Rectangle version. The pre-existing
Repeater delegates are kept verbatim as the fallback arm (software
renderer or shader-compile error — the renderer preference added in
#104 makes that arm user-reachable).

Runtime findings on Qt 6.11 (documented in the file header): inline
GLSL strings are dead (fragmentShader is a URL property), so shaders
are baked with qsb --glsl 440 --msl 12 and shipped under
qml/assets/shaders/ (auto-qrc); and the built-in default vertex stage
emits no qt_TexCoord0, so one explicit shared vertex shader is baked
alongside.

Verified: build + 204/204 tests + both QML audits; RFB (fallback arm,
all three modes live during playback); Xvfb/GL (shader arm, all three
modes, zero shader errors; QSG_VISUALIZE=batches shows one uniform
batch block for the strip).

km
2026-08-02 11:54:01 -06:00
vicrodh 273325f151 revert(qt): restore the 16ms settle driver — frequency lever backfires
Measured on the Mac (samples + QSG_RENDER_TIMING, NPB Large, ambient
off): F0 62fps/155ms-s render / ~2.5ms-frame -> F1 51fps/246ms-s /
~4.8ms -> F1b 29fps/~277ms-s / ~9.5ms. Every frequency cut made each
frame MORE expensive faster than the rate dropped — per-frame cost on
Apple Silicon is dominated by wake/scheduling/power-state overhead
(parking the render thread between frames), not by scene work. The
best measured state is the ORIGINAL 16ms driver, so restore it (this
reverts 9db2176d2 and f2be1cce; the renderer-preference commit stays —
it is a verified no-op here).

The lever that remains is per-frame SCENE cost (F2 render passes /
F3 single-draw spectrum), which is also where the ~6x vs Linux lives
(setShaderResources per batch on Qt's Metal path).

km
2026-08-02 10:51:27 -06:00
vicrodh f2be1ccee8 perf(qt): stage visualizer publishes, commit them in the tick (macOS F1b)
QSG_RENDER_TIMING on the F1 build (Mac, NPB Large, ambient off, 3.5 min
of playback) showed the F1 throttle only bought 62 -> ~51 fps, not 30:
the producer publishes at ~30 Hz AND each publish wrote from/to/
progress directly — at()'s only dependencies — so every 33 ms period
invalidated the window TWICE (publish + tick). ~51 fps x ~3.6 ms render
kept the render thread at ~20-25% of a core.

Stage the publish into pendFrom/pendTo (which nothing visual binds to)
and commit + advance in the driver tick: ONE visual write per period.
Pixel-identical output (the bars hold the settled old frame until the
tick, exactly what progress=0 showed), ~30 fps instead of ~51.

Audits green (resolution + singleton xref), build green.

km
2026-08-02 10:22:06 -06:00
vicrodh 447efe2cd7 feat(qt): consume the renderer preference at startup (#104)
The Settings > Appearance RENDERER row rendered and persisted into the
SAME ui_prefs the Slint reads (settings_qt.rs:2105-2110) but nothing
consumed it — pick "software", restart, and Qt stayed on its default:
the row lied. apply_renderer_preference() now runs before
QGuiApplication::new with the Slint engine's precedence
(main.rs:7825-7848): explicit Qt envs (QSG_RHI_BACKEND /
QT_QUICK_BACKEND) > QBZ_RENDERER > persisted pref > Qt default (Metal
on macOS, OpenGL on Linux — both measured healthy). Cross-frontend
mapping: the GPU tiers (auto/wgpu/gpu/hardware/hw) ARE Qt's default on
both platforms; gl -> QSG_RHI_BACKEND=opengl on Linux and the Metal
default on macOS (the Slint remaps GL to skia(Metal) there,
main.rs:7651-7653); software -> QT_QUICK_BACKEND=software.

Still owed and kept in PARITY-DEBT #104: the crash auto-revert
sentinel and the frame-liveness watchdog — forcing a renderer without
them can leave a broken startup.

Gate: build + 204/204 tests + both QML audits green.

km
2026-08-02 00:30:26 -06:00
vicrodh 9db2176d2e perf(qt): throttle the visualizer settle driver to 33 ms (macOS F1)
The 2026-08-02 macOS measurement (2026-08-02-macos-perf-fix-plan.md)
named the actual burn: not the backend (real Metal, DPR 1, GPU at
50-57%) but per-tick invalidation. The VizSettle driver at 16 ms
invalidated the scene ~62x/s; Qt Quick has no partial damage, so each
tick re-rendered the whole 3440x1440 window (~2 Metal render passes),
~6x pricier per frame on the Metal path than on Linux/Mesa. Paused:
0%. The producer publishes at TARGET_FPS = 30 and the Rust EMA
band-limits the signal to ~6 Hz, so 33 ms keeps one interpolated step
per published frame — the render bill halves and the motion difference
is at the edge of perception. Self-parking, the live gate and the Rust
side are untouched. Expected on the Mac: ~30% -> ~15-18% CPU at NPB
Large playing.

km
2026-08-02 00:30:26 -06:00
vicrodh 7c9e4f1878 fix(qt): type local/plex playlist rows by provenance, not as qobuz
row_to_queue hardcoded source "qobuz" for every row, so a local/Plex
row in a local playlist's detail — adopted into the shared page with
its library rowid (or 2^40 synthetic Plex id) — was re-typed as a
catalog track and passed every Qobuz Connect admission guard
(id > 0 && source != local/plex). A row ⋯-menu enqueue with a peer
active pushed the rowid to the WebSocket as a Qobuz catalog id and the
peer renderer played an UNRELATED track; with no peer the bogus id was
synced and resolved to the wrong track locally. That is the
track-source-admission P0 class from the QConnect hardening review.

Make row_to_queue provenance-aware (the row already carries
row.source). Typed correctly, the existing guards do their job: peer
active -> refusal toast, added nowhere (Slint parity); no peer ->
lands local, correctly typed, sync-on-add skips it. Unavailable rows
(raw path / kind:ref ids) parse to 0 and fail the id > 0 guard, so
they can never reach the WS either. One choke point, which also closes
the latent mistype for the day the online-playlist sidecar merge is
ported (PARITY-DEBT #103).

Verified: build + 204/204 tests. Owner smoke: local row enqueue with
QConnect on a peer toasts "Track not castable to Qobuz Connect" and
adds nothing.

km
2026-08-01 22:20:51 -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 c8559a68b3 feat(qt): port Qobuz Connect — service, flyout, routing and touchpoints
Full-parity port of the Slint QConnect integration per the contract at
qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three
adversarial review rounds on the spec, §12 trap sign-off PASS on both
halves, RFB-verified incl. a real cloud connect, live session
owner-tested). The shared crates are consumed verbatim — zero backend
changes.

- adapter quartet: qconnect_transport_qt (credentials, identity on the
  shared qconnect_settings.db), qconnect_engine_qt (start_track_stream
  fallback chain + feeder helpers + CMAF cursor sync),
  qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect
  recipe, controller routing, echo guards, offline parking B12, startup
  auto-connect)
- QbzQConnect bridge singleton + on_session_entered wiring; one shared
  flyout for both bars (tooltip on the large bar only), Developer >
  Connect diagnostics modal, device-row glyphs, settings rows live
- poll loop: peer-reflection branch before cast, ~2 s renderer reports,
  sync-on-add on every Qobuz-castable add path
- routing: cast → QConnect → local (mute QConnect-first), play arms
  after the set_queue funnel, reorder cloud arm with no local fallback,
  volume/mute/seek remote-first
- touchpoints: lyrics remote anchor, scrobble guarded / Discord
  unguarded, cast suspend/resume, volume-lock lifted when remote

km
2026-08-01 20:23:38 -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 ac9740b83a assets(qt): add the glyphs and brand marks the playlist block needs
63 tinted icon variants and 3 brand marks, split out of the feature commit
so the code diff stays readable.

The icons cover the Playlist Manager (grip-vertical for the grid reorder
handle, chart-no-axes-column for the play-count badge, folder-plus,
library-big, refresh-cw), the folder editor's preset picker, and the queue's
circle-stop marker. Each ships in every tint directory the baker consumes;
a glyph present in only some of them renders nothing in the others, because
icon_tint_qt fingerprints the icon SET and a partial set bakes a partial
cache.

The brand marks are the importer's allowed-source row.

cc
2026-07-31 22:09:51 -06:00