Commit Graph
16 Commits
Author SHA1 Message Date
vicrodh d244213a64 fix(library): harden cross-source catalog data
Preserve per-disc and collection artwork, multi-value genres and remote metadata across Local, Plex, Jellyfin and Subsonic.

Apply source, format and quality filters before pagination, reject invalid CUE rows, and keep Qt library surfaces aligned with the selected physical version.

cx
2026-08-25 18:26:06 -06:00
vicrodh 75a668ed12 feat(metadata): expand the local tag editor
Promote metadata editing to a routed full-page workflow with tabular per-track fields, ordered artist credits, provider identifiers, and compilation handling.

Add bounded artwork selection and retrieval through MusicBrainz, Discogs, and Last.fm. Direct and sidecar saves preflight immutable rows, verify rich tag writes, cache covers by content, and update exact database ids transactionally.

cx
2026-08-24 19:46:59 -06:00
vicrodh c7038a6858 feat(queue): add extended queue view
cx
2026-08-22 20:01:47 -06:00
vicrodh 1c1ea00f6b fix(qt): break the activeTab binding loop, and time the ephemeral open
BINDING LOOP. The Binding's `when` read `viewLoader.item.activeTab` —
through `typeof`, which still registers a dependency — while the Binding
WRITES that same property. Every flyout navigation logged

  QML Binding: Binding loop detected for property "when"

Pre-existing, not from the two-phase commit: reproduced on the
pre-2026-08-21 binary by navigating the same way, where it logs from the
same Binding at its own line number. The guard now lives in a handler on
the Loader's `item`, which runs once per mount and registers no
dependency. Verified by log: 0 "Binding loop", and 0 QML complaints of
any kind on a full navigation pass.

EPHEMERAL OPEN — INSTRUMENTED, NOT SOLVED. Two timers split the work
either side of the parallel read phase, because the remaining ~9 s has no
confirmed owner. The owner reports fooyin opening the SAME NAS folder
near-instantly, which rules out the network as the ceiling and makes this
our code — the obvious suspect is that we read each file's tags in
`extract` and then open and read it AGAIN in `extract_artwork`, plus
decode and re-encode a thumbnail, where a grid-only player needs none of
that up front. That is a design change, not a tweak, so it is measured
rather than guessed at.

The numbers arrive on the next real open ("[ephemeral][perf] parallel tag
reads" / "serial bookkeeping"). I could not trigger one from the test
harness: the entry point is a native folder picker, and the rehydrate
path did not fire from the surface the harness can reach.

cc
2026-08-22 11:04:23 -06:00
vicrodh f50c1b5188 feat(settings): the library folder table becomes its own page
The folder block — toolbar, add-path row, filter, the row-per-folder
table and the scan progress — is the tallest thing in Settings and it
sat at the TOP of Local Library, pushing Albums view, Library > All,
Maintenance, Danger zone and the three media servers off the first
screen. Behind a Manage row, the whole panel fits one screen again.

The shape is the one Blacklist already uses for its manager, and this
view is deliberately its twin: same 64px summary row, same metrics, same
route-recorded-by-the-caller contract.

Nothing about the table changed. LibraryFolderTable.qml is mounted
verbatim, still driven by the settings document and still acting through
the settingsString action keys.

Two things had to travel with it, because SettingsView used to supply
them and a routed page has no access to that:

  - LibFolderEditModal, or the per-folder pencil would open nothing,
    silently.
  - the refresh on entry. A view reached by Back runs no per-view load,
    so it asks for its own, where BlacklistManagerView puts its reload.

Verified in a running window: the route mounts, the seven folders render
with their per-row actions, and Back returns to Local Library with the
section intact.

cc
2026-08-22 00:01:13 -06:00
vicrodh 5b274cab57 perf(qt): acknowledge a route change on the next presented frame
Measured by driving the real window and diffing the captured frames: a
click on Discover > Home left the window with ZERO pixels changing for
590 ms — the flyout did not even close. The click handler, the flyout
close, the sidebar highlight, the page blank and the whole mount all ran
inside ONE turn of the event loop, so the first frame that could be
presented was the one that already had the finished page in it.

The breakdown matters more than the total, because only one part of it
is what it looks like:

    navigate_to (Rust, GUI thread)      7.6 ms
    bridge queue hop                    1   ms
    the QML mount (`built`)           263   ms
    post-mount work before a frame   ~290   ms   <-- `to-idle`

`built` is under half of it. `to-idle` is the number that matches the
freeze, and any future work here should be judged on that one.

So the route is now committed in two phases: arm on the change, build on
the next PRESENTED frame (frameSwapped). Frame N carries the cheap half
— flyout gone, highlight moved, page blanked — and frame N+1 pays for
the mount. Acknowledgement measured at 73-90 ms across five routes and
repeat runs, against 590 ms before.

Two things this had to get right, both caught by capturing frames:

  - The backstop timer was a RACE at 32 ms. Render-at-next-vsync
    (16.67 ms here, per QSG_INFO=1) plus a QUEUED frameSwapped delivery
    lands in ~16-33 ms, so Home acknowledged at +90 ms while Library got
    nothing at all on the same build. 250 ms puts the guard outside that
    range; it now only covers the hidden-window case it was written for.
  - During the armed frame `viewLoader.item` is still the OUTGOING view,
    so the incoming route's discriminators were applied to it. The
    activeTab Binding wrote "home" onto LibraryView — which has an
    activeTab, so the typeof guard passed — emptying its body and
    restarting the page fade over the blank. Both Bindings now require
    the commit to have happened.

Not asynchronous: incubation is time-sliced and was reverted the day it
was tried; every view is still built synchronously.

cc
2026-08-21 23:59:10 -06:00
vicrodh 81e172457f fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a
manual refresh — while the plays were sitting in the database the whole time.
Read off the live file rather than reasoned about: `playlist_play_events` held
3, 9 and 10 rows for three playlists, so the context edge was working perfectly;
`playlist_meta` was empty, and its `.schema` said why — no `covers`, no
`own_image`.

`covers` and `own_image` were added by inlining them into the CREATE. `CREATE
TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who
had run the previous build kept the six-column table, every upsert failed on
"no such column: covers", and the rail JOINs meta against events — so it
rendered its empty state with the evidence one table away. The failure was
invisible by construction: swallowed into a warn, and the UI's empty state is
also its correct state for a user who has played nothing.

A brand-new table is only new ONCE. The moment a build runs anywhere its schema
is legacy and every later column is a migration, so the pair moves to the same
idempotent ADD COLUMN the album history already uses, and the upsert's failure
becomes an error rather than a warn. Verified against a copy of the real
database: the columns land and all 22 events survive. A regression test builds
the exact pre-covers schema, records a play into it, migrates, and asserts the
upsert works and the old event still counts — it reproduces the bug without the
fix, and it also asserts a second `init_schema` is a no-op, since every launch
calls it.

The meta is written by `playlist_qt::load`, so the three playlists already in
the events table surface as soon as each is opened once.

BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader:
Binding loop detected for property source". `_stampRoute` runs inside the
`source` binding, and it read the last path from a `property string` and then
wrote it — a binding depending on a property it mutates. QML detects the cycle,
warns, and stops re-evaluating, which is a broken router and not merely a noisy
log. The holder becomes an object whose MEMBER is mutated: no change signal, so
the dependency is satisfied once and never fires again.

(The second warning on the same file, the `when` loop on the nav-flyout
`activeTab` Binding, is untouched by any of this work and predates it. Left
alone deliberately — it is a warning on a working feature, and rewriting that
Binding is its own change.)

ZEBRA on the playlist detail's track rows, like the local album page. TrackRow
already owns the stripe, so it is one property.

Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 09:01:32 -06:00
vicrodh f0d753a751 fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of 26ceeab82.

TAB SWITCHES DID NOT FADE. The page fade hangs off the router's Loader
`source`, and a tab switch inside a view never touches it — so Discover's four
tabs, Library's and Local Library's cut hard while a route change faded, which
is the same event from where the user sits. `activeTab` is the one name every
tabbed view uses for this (the nav-flyout Binding already routes through it),
so one Connections in the router covers all of them; `ignoreUnknownSignals`
leaves the untabbed views alone. No watchdog and no opacity assignment there: a
tab body is built synchronously in the same turn, so `restart()` alone — which
sets the page to `from` — is the whole transition. The `running` guard keeps a
tab stamped right after a route change from restarting that route's fade.

RECENTLY PLAYED PLAYLISTS DREW PLACEHOLDERS. The rail published only
`artwork_url`, taken from the detail page's `cover_url` — and that field is
empty for every playlist WITHOUT a graphic of its own, which is most of them
and is exactly why the detail page falls back to a collage. A local playlist has
no Qobuz graphic at all, so it could never resolve one. The meta store now keeps
the member covers and an own-image flag beside the url, and the card gets both
arms it already knows how to draw: contain-fitted single graphic, or the mosaic.
All of it rides `art_url` and lets `attach_cached` fill the path —
`artwork_qt::cached_path` already classifies http, Plex and on-disk paths, so a
custom cover and a local file resolve through the same pass as a Qobuz url.

ITEMS PER CAROUSEL WAS ONE NUMBER FOR THE WHOLE PAGE. Tauri's was per rail
(`HomeSettingsModal.svelte`), and the global was a simplification that lost the
point of the feature: a 25-wide "New Releases" beside a 10-wide "Recently
Played" is why anyone opens it. Storage is now a row per section — absent means
the default, and setting a rail back to the default DELETES its row, so the
file only carries what was actually changed. The select moves into each row of
the configurator, and the cut moves into `order_by_prefs`, keyed by the PREF id:
`mostStreamed` resolves to a per-tab variant candidate, so the section's own id
is not always the id the user configured, and that loop is the only place both
are in hand.

OPEN OVERLAYS LET THE WHEEL THROUGH. A MouseArea consumes presses and NOT wheel
events, so with the configurator open the pointer still scrolled the page
underneath — over the panel it moved the panel's own list and the page behind it
at once. These overlays are plain Items rather than Popups, so nothing grabs for
them and each has to stop the wheel at its own surface: the configurator, the
genre popup, the playlist-category popup and the Local Library funnel.

Release build clean, offscreen smoke green, 421/421 and 215/215.

cc
2026-08-19 07:34:28 -06:00
vicrodh 26ceeab828 feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.

MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.

Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.

PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.

The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.

SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.

CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.

APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.

RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.

ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.

Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.

Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.

Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cc
2026-08-16 11:54:05 -06:00
vicrodh 016eb3cd23 feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.

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

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

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

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

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

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

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

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

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

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

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

km
2026-08-10 18:57:02 -06:00
vicrodh 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