Commit Graph
6110 Commits
Author SHA1 Message Date
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
vicrodh 0bfe297776 fix(qt): gapless keeps its state across every backend change
Owner, 2026-07-31: "hay que mantener el status activo, sin importar el cambio en
el backend." Gapless now survives a backend switch, ALSA included.

A deliberate divergence from BOTH references. Tauri forced it off when moving to
ALSA (`SettingsView.svelte:3409-3416`, "not compatible with ALSA Direct") and
Slint copied that (`settings.rs:1232-1237`); the two agree despite the different
wording, because in the engine's own vocabulary `using_alsa_direct` IS
`backend_type == Alsa` (qbz-player/src/player/mod.rs:725-729).

The exclusion looks broader than the hardware demands: `PlayNext` is
backend-AGNOSTIC — it appends to the live engine and refuses only on a missing
engine, a sample-rate/channel mismatch, or an active streaming source
(mod.rs:3553-3586) — and ALSA Direct does have an engine with its own writer
thread. Within one album at a constant rate there is no mechanism here that
should break. Nothing in qbz-audio or qbz-player is touched; this only stops the
frontend from forcing the user's setting off.

What this port had was worse than either reference: the guard was missing
entirely, so EVERY backend change killed gapless — including switching to
PipeWire, where it works perfectly. That is what read as "alsa breaks gapless":
the switch itself turned it off, and re-enabling it was what fixed it, not the
restart it happened to coincide with.

The `streaming-only` cascade still disables gapless and is untouched — that one
is about there being no cached file to hand the engine, not about the backend.

NEEDS REAL-HARDWARE VERIFICATION on the owner's DAC: the failure mode to watch
is a transition that renegotiates the stream mid-album (a rate change), which
the format-match guard should already refuse.

cc
2026-07-31 08:00:16 -06:00
vicrodh c8e4f415e7 fix(qt): a backend change only disables gapless when moving TO Alsa
The guard was missing, so EVERY backend change silently turned gapless off —
including switching to PipeWire, where it works perfectly. A user who did not
notice the toggle flip reads that as "alsa breaks gapless", which is exactly how
it was reported.

The reference is conditional: `settings.rs:1232-1237` disables it only for
`backend == AudioBackendType::Alsa`, and that in turn matches Tauri
(`SettingsView.svelte:3409-3416`, "not compatible with ALSA Direct"). Those two
are faithful to each other despite the different wording: in the engine's own
vocabulary `using_alsa_direct` IS `backend_type == Alsa`
(qbz-player/src/player/mod.rs:725-729). Only the Qt port dropped the `if`.

Investigated and RULED OUT while tracing this, recorded so it is not re-derived:
the audio thread's `thread_settings` and the Player's `audio_settings` are the
SAME `Arc<Mutex<AudioSettings>>` (created at mod.rs:1370, cloned into the thread
at :1371, stored on Self at :4067). `reload_settings` therefore does reach the
running audio thread, and no restart is needed for a settings change to land.
The apparent "restart makes gapless stick" is this cascade: the switch persisted
`gapless_enabled = false`, and turning it back on is what fixed it — the restart
was just when that happened.

Whether gapless SHOULD work on Alsa at all is a separate, deliberate rule
inherited from Tauri and left untouched here: the engine's `PlayNext` handler is
backend-agnostic and refuses only on a missing engine, a sample-rate/channel
mismatch or an active streaming source (mod.rs:3553-3586), so the blanket
exclusion may well be broader than the hardware demands. That is a product call
on the PROTECTED bit-perfect path and needs the owner's word plus real testing
on the DAC.

cc
2026-07-31 07:41:06 -06:00
vicrodh 8f416704a8 fix(audio): verify the forced rate against the key that was forced
Owner-authorised change inside the protected backend, and it touches no audio:
the fix is entirely in what the log CLAIMS.

`get_pipewire_current_rate` wrote `clock.force-rate` and then read `clock.rate`
to check it. Those are different things in PipeWire — `force-rate` drives the
graph, `clock.rate` is the graph's NOMINAL default and does not move when a rate
is forced. So the check compared its request against a key it had never touched,
got the untouched 48000 default, and logged "Audio may play at wrong speed" over
perfectly correct output. The retry never helped because the delay was never the
problem.

Measured directly on the owner's box with the app closed:

    clock.rate        = 48000     <- what the check read
    clock.force-rate  = 96000     <- what the code wrote, and what the DAC showed

The owner had been living with the warning precisely because the hardware
disagreed with it: "el dac muestra los 96 ... por eso no me he clavado".

Now: read `force-rate`, fall back to `clock.rate` when nothing is forced (it
reads 0 then), which is the honest answer in that case. The parse is split into
`read_pw_setting(key)` so the two keys cannot drift apart, and it matches
`key:'<name>'` EXACTLY — `clock.rate` is a substring of `clock.force-rate`, so a
`contains` would cheerfully report the forced rate when asked for the nominal
one.

Worth fixing even though the audio was always right: in a bit-perfect player a
warning that cries "wrong speed" over correct output trains you to ignore it,
and buries the day it is true.

Not addressed, observed while measuring: `clock.force-rate` stays at 96000 after
the app exits, so the system graph is left pinned until something else changes
it. May well be deliberate; flagged, not touched.

cc
2026-07-31 01:52:45 -06:00
vicrodh d1862e90da fix(qt): the Source column draws the real Qobuz and Plex marks
Owner: "si es de qobuz, icono de qobuz, si es de local (folder o plex) cada uno
tiene sus svg; Slint y Tauri los tenían bien."

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cc
2026-07-30 17:14:40 -06:00
vicrodh f1b4241342 feat(source): add qbz-source, the source-agnostic media seam
Stage 1 of the seam the owner asked for: "son vistas súper híbridas donde se
mezcla Qobuz/Local(Folders/Plex) ... hay que hacer una solución agnóstica
porque no tardamos en meter a Jellyfin y quizás Navidrome". Nothing is wired
yet — this lands the crate, its three source implementations and its tests.
`qbz-qt` and `qbz` are untouched, so this commit changes no behaviour.

The problem it exists to end, measured rather than assumed: 129 source-branching
sites across 30 files in qbz-qt alone, SEVEN distinct item->tracks resolvers
none of which can reach the others, FIVE non-interchangeable artwork pipelines
over one correct taxonomy, and six incompatible source vocabularies — three of
which cannot express Plex at all, including the PERSISTED one (`AlbumSource` is
`Qobuz|Local`). Every hybrid view re-wires the per-source handling by hand,
which is why something is always missing.

One `Source` trait covering the four concerns together — tracks, metadata and
origin, artwork, playback — because splitting them is exactly what lets a view
forget one. A `SourceRegistry` with claim-priority ordering (Qobuz, Plex, then
Local; Plex-before-Local is load-bearing). `RegistryResolver` implements
`qbz_mixtape::ItemResolver`, so that crate and the Slint glue stay byte-
untouched and keep compiling.

The three measured bugs die structurally, not by discipline:
- The local DB was reopened PER CALL, so a My QBZ collection logged one
  "Opening library database" per item (34 measured live). `LocalSource` owns a
  pooled handle, and it hands out no guard — only `with(|db| ...)` — so a
  `!Send` `&LibraryDatabase` provably cannot cross an await. `qbz-mixtape`'s
  sync-resolver constraint becomes an API property instead of a comment.
- A `plex:<hash>` album key reached an API that wants a rating key. `MediaRef`
  has no public constructor: only `registry.claim` mints one, and
  `PlexSource::claim` rejects that shape in track position. The survey also
  found a second, undiscovered face of the same bug — "Go to album" on a Plex
  track row opens an empty page.
- Local rows in a hybrid list rendered placeholder discs. Traced to its real
  mechanism: those items are stored with `artwork_url: None` and the only
  backfill is behind a Qobuz-client gate, so offline or pre-login the art is on
  disk and never shown. `Source::artwork` is one call per row whatever the
  source, and keeps the cheap/cold split the local path already had.

Adding Jellyfin is one new file, four diff lines inside this crate, and one
line in `auth_qt.rs` — and the design states this only holds BECAUSE artwork
sits on the trait.

36 tests. The id tests derive their fixtures from `PLEX_TRACK_ID_FLOOR` instead
of transcribing them, which is not pedantry: the survey's own id table — the
artefact meant to be the definitive reference — paired a namespaced id with the
wrong rating key, and the transcribed test caught it on the first run.

Design and adversarial review (6 BLOCKER / 10 MAJOR / 7 MINOR, all corrected):
qbz-nix-docs/qt-frontend/2026-07-30-source-seam/.

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

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

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

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

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

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

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

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

cc
2026-07-30 15:19:46 -06:00
vicrodh b8b493bd59 fix(qt): persist the player volume, so a launch stops starting at 100%
PARITY-DEBT #3 — the cheapest AUDIBLE row in the inventory. Volume was never
written and never restored: `setVolume` reached the engine and stopped there.
Set it to 15%, quit, relaunch, and playback came back at full scale. On an
exclusive-mode DAC at night that is not a cosmetic default.

1:1 with the Slint: persist on drag-END only (`NowPlayingState.persist-volume`,
PlayerBar.slint:864-866 -> qbz/src/main.rs:14413-14417), restore at shell entry
(qbz/src/main.rs:220-227), and let the poll loop mirror the engine back onto the
slider so nothing else has to publish it.

Drag-end matters here rather than being a nicety: ui_prefs.json is co-owned with
the running Slint app and both processes do a whole-document read-modify-write,
so a write per drag tick is a write per pixel into a shared file. `QbzSlider`
only had `changed`, which fires on every tick, so it gains a `released` signal
carrying the settled value — both now-playing bars use it.

Two supporting bits: `QbzPlayer.persistVolume` (the engine setter stays
per-tick, only the persist is debounced by the signal), and
`settings_qt::read_pref_f32` — the crate had no public ui_prefs reader, theme_qt
kept a private one, and a third private copy is exactly what the write
discipline at the top of settings_qt exists to prevent.

Verified the key name against the live file: `volume` is already present in the
shared ui_prefs.json alongside 60 other keys, so this reads and writes what the
Slint build reads and writes.

cc
2026-07-30 09:46:19 -06:00
vicrodh 424c2ea552 fix(qt): the blacklist reaches the queue, and one dead track no longer wedges it
PARITY-DEBT #1 and #2, the two worst AUDIBLE rows in the inventory.

**#1 — a blocked artist still played.** The store was open (auth_qt binds it in
all three session paths), the Blacklist Manager worked, and `search_qt` filtered
against it — but `playback_qt` had zero blacklist references, so a blocked
artist entered the queue from an album, an artist's top tracks, a label "play
all", a radio or a compilation. The one row where the user has explicitly said
"never play this".

The Slint applies the filter at ~18 separate builder sites
(`filter_blacklisted_queue`, playback.rs:2624-2641). This port funnels every
play path through `set_queue_stamped` and every enqueue through `stamped`, so it
needs exactly two — which is also why the gap was total rather than partial:
one choke point that nobody taught.

The start index is the part the reference gets for free by filtering before it
computes the index. Here the caller has already picked an index into the
UNFILTERED list, so dropping rows ahead of it would silently start the wrong
track. `filter_queue_with` remaps it, and the arithmetic is split out as a pure
function with five tests (clicked row survives / clicked row was blocked / the
whole tail blocked clamps to the last survivor rather than past the end /
everything blocked yields no index / nothing blocked is identity).

**#2 — one unavailable track stopped everything.** `play_queue_track` logged
the error and returned, so a region-locked or missing track ended the session,
and the early return also skipped both refreshes: the bar kept showing the
PREVIOUS track over a silent player. Tauri #467, re-introduced.

Ported from playback.rs:766-818: classify the error, then walk. A 403 or the
#637 client-side back-off is NOT the track's fault (every track 403s the same
way) so it stops cleanly with its own message instead of burning five good
tracks looking for one that works. A transient failure is left alone — the
client already retried it. Only a TERMINAL unavailability skips, bounded at 5
consecutive, reset the moment any track actually starts. The recursion returns
a boxed `dyn Future + Send` for the reason the reference documents: an inferred
`impl Future` makes Send-ness self-referential and the compiler hits a query
cycle.

Nothing in qbz-audio or qbz-player is touched.

cc
2026-07-30 09:46:06 -06:00
vicrodh 70e161bc08 docs(qt): playback_qt's header lists OWED PORTS, not "deliberate cuts"
It still advertised the gapless gap after the fix landed — the exact failure
the parity inventory was built to stop. A comment that outlives its gap is
worse than no comment: it reads as tracked.

Three corrections. The gapless entry is gone (it is wired). The blacklist entry
claimed "store not open", which is false — artist_blacklist is bound in
auth_qt's three session paths, so the store was open the whole time and only
the consumption was missing. And the framing: these are debt with an owed port
(TRACK-RULES rule 8), not scope decisions, so each now carries its
PARITY-DEBT.md row and its severity.

New cuts go in that file, not in this header.

cc
2026-07-30 08:29:08 -06:00
vicrodh 809dafce1a fix(qt): answer the engine's gapless request instead of advancing after silence
The setting was connected and the backend was working. The log says so in one
line: "Gapless: approaching end of track (54s/64s), requesting next" — the
audio thread raises `gapless_ready` ~10s before the end (player/mod.rs:3911-3929,
gated on `gapless_enabled`) and waits for the frontend to hand it the next
track's bytes through `Player::play_next`, which appends to the same rodio sink
and is what makes the transition sample-accurate.

Nothing in this port answered. `playback_qt`'s own module header recorded the
cut from the POC phase — "gapless prefetch (`play_next`) ... out of scope.
Auto-advance therefore plays each successor at fetch time (audible gap between
tracks) — the engine's gapless path is NOT wired". So every advance fell
through to the end-of-track edge instead: on Dark Side of the Moon, Speak to Me
ended at 07:21:40.8, the poll noticed at 07:21:41.7 and the successor's initial
buffer took another 300ms — a second of silence exactly where that album has
none.

Ported from playback.rs:5362-5476, both arms:
- streaming: peek_upcoming(1) -> fetch_for_gapless_resolved (L1/L2 player cache
  -> network; the offline tier is None here for the same reason every other
  play path in this module passes None) -> play_next.
- local: resolve the path from the ephemeral store or library.db, skip CUE
  virtual tracks (they share one album image file), then play_next_dsd for
  .dsf/.dff and raw bytes otherwise. The reference calls
  `qbz_dsd::is_dsd_path`; `qbz-dsd` is not a dependency of this crate and
  local_playback.rs:139 already makes the same call from the extension, so this
  follows the port's own idiom rather than adding a crate.
- the `gapless_requested_for` guard, cleared on the track-change edge so a
  track reached BY a hand-off can arm its own successor.
- the stop-after guard, kept 1:1 although it is a no-op today (this port has no
  UI that marks a track): without it the engine hands off seamlessly and the
  marker never fires, which is the trap the reference documents.

Nothing in qbz-audio or qbz-player is touched — this consumes `play_next` /
`play_next_dsd` / `fetch_for_gapless_resolved` exactly as the Slint does.

The seamless hand-off needs no new detection: the engine changes `track_id`
while `is_playing` stays true, and the existing track-change edge already
reconciles the queue pointer through `sync_current_to_id` and refreshes the
card. The end-of-track edge cannot double-fire on it either, since it requires
`was_playing && !is_playing`.

cc
2026-07-30 07:33:37 -06:00
vicrodh 01f3819da1 fix(qt): the now-playing audio button opens the Normalization + Gapless flyout, and reads the real state
It was not a flyout and it did not read the persisted value, which together
made normalization impossible to turn off.

- `shell/AudioSettingsMenu.qml` (new) ports the `audio-menu` PopupWindow both
  bars mount (PlayerBar.slint:686-705, PlayerBarSmall.slint:635-712): 252 wide,
  two 44px ToggleRows with 12px side padding and a 12px gap to the switch,
  label text-primary 13px over hint text-muted 10px at spacing 1 — the numbers
  are PlayerBar.slint's file-private ToggleRow (:80-115).
- `PlayerBar.qml` had `normTouched` / `normLocal` and toggled normalization on
  click. `NowPlayingBarSmall.qml` was worse: `property bool normOn: false`,
  local state that started at false no matter what was persisted, so the FIRST
  click on that button always sent `normalization = true`. Both now just open
  the flyout. `active` mirrors normalization on the full bar and is neutral on
  the Small one, which is the reference's own asymmetry
  (PlayerBarSmall.slint:628-630 states it out loud).
- Gapless had no now-playing control at all.

The local shadow state existed because the document never carried the truth:
`publish_settings()` ran ONLY on `navigate_to("settings")` and on a language
change, and no handler in `settings_qt` ever republished. So until the user
opened Settings the shell read an empty document, the control drew OFF while
the backend had it ON, and the first interaction wrote the wrong value back.
Slint has no equivalent hole — `SettingsState` is a global seeded at startup.
So: seed the document at shell entry, and republish after every audio apply.
Verified by effect, not by diff — the boot log now reports the snapshot as
5006 bytes carrying the persisted `normalization` and `gapless`, before any
view is opened.

Seeding at shell entry also seeds the now-playing stamp's two output LEDs,
which `publish_snapshot` derives from the same audio settings.

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

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

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

cc
2026-07-29 21:24:12 -06:00
vicrodh acf23abc9b feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.

Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.

QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.

Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.

Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.

SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.

Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.

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

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

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

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

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

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

cc
2026-07-29 16:26:11 -06:00
vicrodh a3b89107fc fix(qt): table headers line up, the mini rail is centred, tooltips have a background
Three of the owner's visual reports, one theme: geometry that was derived
in two places and drifted.

The track table laid its header out from the LABELLED columns only, so
the heart and the overflow menu took no space there and every label sat
right of the column it names. Header and rows now come from one shared
set of widths, including the responsive arms -- if the header hid columns
on a different condition than the row, the drift would come back only on
narrow windows.

The collapsed rail goes back to Slint's 64px. It had been 50 by owner
request, and the measurement showed why that was the actual defect: the
rail centred its row at 25 while the header's own sidebar toggle, sitting
directly above it, centres at 30. Symmetric padding inside a 50px rail
still reads as misaligned because the eye compares the glyphs to the
header control, not to the rail; chasing it from inside bought the
alignment at the price of a visibly asymmetric hover pill. At 64 both
references land 2px apart and the padding is symmetric again. The row
stays a literal 34 rather than being derived from the width -- deriving
it is what coupled the two tokens and would have silently grown every row
to 48.

Tooltips: the port had none, while Slint has a shell-level overlay. This
adds the overlay rather than a Popup per call site.

cc
2026-07-29 16:25:57 -06:00
vicrodh 6b5226acf9 fix(qt): covers are actually downscaled, and non-square art keeps its ratio
The owner's clue was the whole diagnosis: the same image looked fine at
220px in the hover preview and bad at 150px in a card. So not the source,
and not the URL variant either -- every Qt producer picks exactly what
Slint picks, `large` (600px) into a 200px cell, 3x headroom.

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

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

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

cc
2026-07-29 16:25:42 -06:00