033b838b98bc18459f523b24f46774a37d457a8c
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
07b0ba818e |
feat(qt): keyboard shortcuts cheatsheet and customize editor (B3)
km |
||
|
|
5130e872e7 |
feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km |
||
|
|
0cca9abc58 |
feat(qt): hotkeys core, QbzHotkeys bridge, shortcut i18n catalogs (B1)
km |
||
|
|
c0f4d2f1b7 |
feat(qt): immersive player bar and search cortinilla (B5)
km |
||
|
|
0b6e8ed7c9 |
feat(qt): immersive split view, lyrics, track info, suggestions, queue tabs (B4)
km |
||
|
|
46c1c42408 |
feat(qt): immersive atmosphere and focus panels (B3)
km |
||
|
|
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 |
||
|
|
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 |
||
|
|
b5298b78bd |
feat(qt): immersive bridge, viz two-source enable, coverflow payload (B1)
km |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
d5bf4398ac |
fix(qt): Library sub-tab switch no longer segfaults, and the pane keeps its corners
Three identical faults in dmesg over three days, all `segfault at 8 ... libQt6QmlModels.so.6.11.1[420c5]`. `nm -DC` puts that offset in `QQmlDelegateModel::model()`; the QVariant return means sret, so %rsi is `this` and address 8 is a null pointer plus the member offset. `QQuickItemView::model()` reads its d-pointer, checks a QPointer guard and then calls straight through without testing the value. The re-entry is `onContentYChanged`, not `onModelChanged` as first assumed: `setModel()` emits it from inside the window where the old delegate model is already gone. Switching to Labels re-derives the model AND clamps contentY to a shorter list, which is the emit -- hence the intermittency, since at contentY 0 nothing is emitted and nothing crashes. Reproduced in plain qml6 with no app and no login, and bisected: readback in onContentYChanged dies, in onModelChanged survives 401 swaps, cached array survives everything. The fix is to stop asking the view for its own model. A tree-wide audit found `.model` is the only member that is unsafe this way in 6.11.1 -- count, contentHeight, indexAt and itemAtIndex all null-check -- and the three LocalLibrary tabs already cached their arrays, which is why this stayed a Library-only bug. Also: `clip: true` is a RECTANGULAR scissor in Qt Quick and does not follow `radius`, so the album/artist header band painted over the content pane's rounded corners. Slint clips to the rounded shape on the same element, which is why the identical structure is correct there. The mask sits OUTSIDE the frame rather than at a high z, because ADR-009 pins in-pane modals at z >= 3000 with no ceiling and a z fix would be a standing arms race. cc |
||
|
|
f3f0acc192 |
fix(qt): favorites and pins tell the truth on every surface
The reported symptom was a 404 un-favoriting an artist. The cause was one
word: the shared client builds its query key as `format!("{}_ids", ty)`,
so it wants the SINGULAR noun, and every Qt call site passed the plural.
`artists_ids=` is not a parameter Qobuz knows, so /favorite/delete
answered 404 -- and /favorite/create had been failing just as silently in
the other direction. The trap is that the SAME identifier takes the
plural for /favorite/getUserFavorites, which is why the migration read
right and was wrong; label reads were the mirror image, sending the
singular where that endpoint wants plural and 400ing on every page open,
so a label could never be unfollowed.
Playlists were worse than wrong -- they went to /favorite/create at all.
That endpoint has no playlist_ids; Slint routes them to library.db and
says so in a comment. Rerouted.
Fixing the write then exposed the display: the toggle read a favourites
cache that only filled after the Library view had been opened once, so on
a fresh launch a favourited album drew an EMPTY heart and the click
REMOVED it. A backend fixed without its display is worse than neither,
because the two now disagree. So every producer stamps its real state at
document-build time from the same authority the write uses, and there is
one settle point every heart reconciles through.
Pinning gets the same treatment plus the fan-out it was missing, and both
now patch badges by signal instead of republishing a document -- a
republish hands `model:` a new array, which resets scroll and tears down
the delegate model.
cc
|
||
|
|
82b88ee8e0 |
chore(qt): add scripts/qt-run.sh
The Qt-track sibling of slint-run.sh, and deliberately not a copy of it. No memory tiering and no cgroup cap: qbz-qt does not depend on qbz-ui, so the 20-30 GB compilation unit never enters this graph. That is the point of the track. No RUSTFLAGS and no +nightly either. This worktree's target dir was built on stable with rustflags=[], and RUSTFLAGS is a fingerprint input, so exporting mold would buy a from-scratch rebuild of the workspace to save seconds on a two-minute incremental. MOLD=1 opts in and warns first. The two QML audits run before the build, because cargo check sees nothing of the QML: a missing component or a bridge member that does not exist compiles clean and throws when the view opens. They take a second. SMOKE=1 runs the track's offscreen gate; a guard refuses to start while another cargo/rustc is alive, since one Slint build in a sibling worktree is enough to freeze the box. cc |
||
|
|
89c54cd5bd |
feat(qt): restore and persist the main window geometry
The reported symptom was titles eliding early in every bar mode. Root
cause was not the breakpoints: Main.qml pinned 1280x800 on every launch
while Slint restores window_width from the shared ui_prefs.json (1716 on
the owner's profile). 1280 sits one tier below the 1366 breakpoint, so
Classic's song card capped at 446px instead of reaching its 560px cap --
the ~114px of early elision.
Restoring alone would have fixed that. Saving is the half that makes the
feature real, and it is where the risk lives, so it is built to Slint's
semantics rather than invented: floating-size-only keys, the app minimum
as a gate, the >0.5px dirty check, maximized only ever re-applied.
Two things Qt makes harder than winit and both bit:
Visibility is ONE enum where Minimized replaces Maximized, while winit
keeps them orthogonal and still reports maximized while iconified. So
the flag is latched from the last non-transient visibility. The first
attempt read that through a derived property inside the handler for the
same signal that dirties it, which is one transition stale -- it passed
grep, qmllint and rustfmt, and was caught only by driving real
transitions under qml6. It is a function now; a function has no cached
value to be stale.
And the writes share a file with the shipping Slint app. A truncating
fs::write plus a json!({}) fallback on parse failure meant either side
could flatten the other's document: Slint reading inside the truncate
window falls back to UiPrefs::default() and writes those defaults back.
Every writer in the crate now goes through one atomic read-modify-write
(tmp + rename), and a file that is present but unparsable aborts the
save instead of being rebuilt from nothing.
The Slint side keeps its own non-atomic write, so the cross-process race
is narrowed, not closed.
cc
|
||
|
|
81c43a114e |
fix(qt): glyph colours follow the theme, on light palettes and on accent fills
Two defects with one cause: a tint name that means a frozen hex. "primary" is the legacy alias of literal #ffffff, not Theme.text-primary, so every control passing it drew a white glyph. On the eleven light themes that is white-on-light -- the header back/forward chevrons, the sidebar toggle and the carousel arrows the owner marked. They now pass the theme-following names, which icon_tint_qt bakes at runtime. The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that predate the runtime tinter collapse into those names, and the qrc floor underneath them becomes polarity-aware: dir_for() returns "" whenever the cache dir is unavailable, a bake fails or the theme document has not been published, and that path used to resolve textPrimary straight back to the white bake -- reproducing the bug it was fixing, app-wide, in the degraded state. On accent fills the fix is a deviation from Slint, approved by the owner after measurement. A fixed white glyph on Theme.accent falls under 2.6:1 on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast. Slint hardcodes #ffffff there (CircleAction.slint:72 and three others), so the bug is upstream. accentText alone is not the answer either: it is picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn, worse than the black it replaces. So QbzTheme picks the best-contrast tint by real sRGB luminance against the live accent, and the labels beside those glyphs use its colour twin -- BrowseGenreButton and LibraryView were drawing a white glyph next to an accentText label inside the same pill. Rounds out the two parity items the same files owe: QbzNavButton gains the ambient arm from Carousel.slint (wired at the six carousel sites, not left dead), and QbzCircleAction rounds its glyph to whole pixels the way CircleAction.slint does. cc |
||
|
|
8af04c5272 |
fix(qt): NPB Small mounts the shared song card
The Small bar hand-rolled a reduced copy of the song card inline: album art plus a two-line title/artist block. All three features the owner reported missing live in SongCard.qml and only there -- the "layers" playback-context glyph with open-context(), the album name in the meta row, and the hover area that publishes the floating art preview. The overlay itself was already mounted shell-level with no bar-mode gate; it simply never had a writer while Small was loaded. PlayerBarSmall.slint mounts the shared SongCard with text-center, so this is the port catching up rather than a redesign: art-size 37, art-border 3px surface-card, show-badges false, title 13px, art-text-gap 9. The 18px meta row fits the 39px controls row with ~1px clearance, and the loading scrim on the cover comes along with it, as it does in Slint. Also folds the responsive tier arms into shared helpers so the four bar modes cannot drift apart on the 1366/1920 breakpoints. cc |
||
|
|
8b26cda6f6 |
feat(qt): floating album-art preview on now-playing cover hover
Port of ArtPreviewOverlay.slint: hovering the small cover floats a 220px preview above it. Missing in every bar mode except Large. Non-interactive and mounted last in AppShell, both for the reason the .slint records: the original used a PopupWindow and it stole the hover from the trigger, so the preview flickered open and shut. An overlay that cannot receive events cannot do that, and clicks pass through. The anchor rides QbzShell like the drag ghost does -- trigger and overlay are in different files with no shared id. cc |
||
|
|
87b18fa7f7 |
feat(qt): wire local-network casting end to end
The two cast buttons in the now-playing bars rendered and did nothing: no service, no bridge, no picker. This ports crates/qbz/src/cast_service.rs onto the shared qbz-cast crate (discovery, both device families and the media server are the crate's, not reimplemented) and hangs a QbzCast singleton off it. Discovery is armed only by openPicker() and stopped by closePicker(), so a user who never opens the picker sends no mDNS/SSDP traffic. Connecting stops the local backend through its public seam and re-casts the current track at the streaming preference clamped by the per-renderer cap. Playback is routed at four seams in playback_qt (new track, toggle, seek, volume) and the local poll yields while casting — without that the two polls fight over the bar every second. Logout and exit tear the connection down, or the renderer keeps playing after the app is gone. Deliberate gaps, each named in the module header: no offline-cache tier, no QConnect suspend/restore, no Plex casting (the Slint carries the same TODO), and lyrics do not follow a remote position yet. cc |
||
|
|
c56fab3c4e |
fix(qbz-qt): covers were never actually resized, plus the adversarial parity round
**The scaled-cover fix from the last round produced nothing.** The seam ran — 462 requests in one offscreen smoke — and wrote zero files, so the owner was right that nothing improved. The artwork cache stores files as `.img`, and `image::open` picks its decoder from the EXTENSION, so every call failed silently. Sniffing the content instead yields 101 derivatives on the same run. The header atmosphere had the identical bug and had therefore never produced an image either, which is why the flat fallback was all anyone ever saw. Reported as fixed last round without checking that it ran. Verified by effect this time, not by reading the diff. The rest came through an analyse / refute / implement / verify pipeline, and the adversarial passes earned their keep: - The sidebar mini rail: icons sat left of centre because their slot kept its fixed 24px width while the rail collapsed, so the hit target was rectangular and off-axis. The rail narrows, rows are square, and the mini state gains the folder flyout and tooltips it never had. The reviewer caught a comment claiming the width change was a one-token revert when it is two — corrected rather than left to mislead the next reader. - Playback context now travels from every producer, not the handful that happened to stamp it. - Collapsible per-tab search in Library, and label artwork. - Every View-all destination Home offers, plus the label page and its releases. - Collapsing the sidebar drops a Large bar back to New, live, without overwriting the user's persisted preference. cc |
||
|
|
2a5b5ac16e |
feat(qbz-qt): theme-following icons, the real header atmosphere, sharp covers, playback context
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light themes secondary, muted and accent were simply wrong, and accent was wrong on any theme whose accent is not #4285f4. Slint recolours at render time, which this path cannot do — so the tint is generated once per colour into the cache and loaded through the same Image+svg path the bakes already use. No effect, no shader, so it cannot hit the "renders nothing on software" failure. The sweep found the part the diagnosis had missed: "primary" was two meanings welded together — max-contrast-on-theme-surface, and literal white for glyphs on hosts that are dark under every theme. Remapping it globally would have painted dark glyphs on dark scrims in fourteen places. The vocabulary is split instead, with "primary" left as an alias so nothing changes until it is migrated deliberately. **The album header gradient was Slint's fallback, not its design.** Slint paints a blurred artwork field and only falls back to the flat two-band gradient when that image is unavailable; the port shipped the fallback. The field needs no shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look, not an optimization; everything after it works from eight pixels of colour, which is why it reads as atmosphere rather than as a blurred cover. It comes out of the same decode that already produced the flat tint. **Covers were point-sampled.** Canvas drawImage does not filter when it scales, measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in a 200px cell throws away eight of every nine pixels, and the error is aliasing, not softness. Rust now produces a derivative at the drawn device size so the draw is 1:1 and there is no resample at all. **The now-playing context stack.** It had no Qt counterpart: the model carried context_kind and context_id, some builders stamped them, and nothing ever read them back. The album path only appeared to work by coincidence, because the glyph was hardcoded to open the album. Plus: Discover's missing rails with real play history, Recommendations on the integrations, queue drag reorder, and the playlist detail collage. cc |
||
|
|
a1c771c39e |
fix(qbz-qt): row buttons, cover triggers, light-theme contrast, flyouts, genre filter
**Track-row buttons did nothing.** Declaration order is z-order in QML, and the row-wide MouseArea added for right-click was declared last — so it covered the heart, cloud and menu buttons and swallowed their presses. propagateComposedEvents cannot rescue that: a press is not a composed event, which is why only the row's own double-click still worked. **Covers appeared only after a scroll.** Three compounding defects, not one. The window report fired on contentY and on model change, both of which happen while the tab body is still hidden behind its loading flag, so the report that mattered hit the not-visible early return and was discarded with nothing to re-fire it. The debounce reset on every change, so a burst of mount-time reports kept pushing its trailing edge out. And a single pending slot meant that when two surfaces are mounted at once — the artist rail and that artist's grid — the second overwrote the first, so one pane was never requested at all. Eviction had the same shape: each report deleted everything outside its own array, so the two panes deleted each other's covers. **Latest release had no cover** because the artist page's collector skipped it, along with Appears On and the playlists strip. **Light themes.** The mechanism is Slint's, from 2.0.2 and issue #638: the on-surface arm on circle actions, header-light deciding the header foreground only when a dark backdrop is actually painted, and the circular backing behind the track-row play glyph. Ported as specified rather than approximated. Two icons rendered in their SVG's own colour because they pointed at the raw asset instead of a baked tint — a sweep found the rest of that class. **Navigation flyouts** exist, one component serving both the sidebar and the header nav modes. **Filter by genre and the Discover sort now work**, everywhere the control is drawn. The genre ids were already a parameter on the discover fetch that the port always passed empty. cc |
||
|
|
9ab3d7db96 |
fix(qbz-qt): local albums route to the local view, and the detail-page parity round
**The empty album view.** Opening a local album from Library "All" landed on a blank page. The mixed feed hands Qobuz and local albums to the SAME card, and the card always called the catalog open — so a local id, which is a group key or a filesystem path, went to /album/get and came back 404. It was in the owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what changed is that clearing the stale document turned "shows the previous album" into "shows nothing", which is how it surfaced. Routing now happens in open_album itself, using the predicate the feed already uses to route favourites, so every call site is covered at once instead of card by card. **Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted from THIS page's artwork through the ported header_tint — not the now-playing ambient triad, which is a different subject with a different algorithm. **Artist page.** The catalog/library toggle existed but was broken: a hand-rolled copy of the shared tab bar whose delegate walked the wrong parent chain, so it threw on every evaluation and never took its active colours. It now mounts the shared control. Added the JUMP TO label, the tab-strip search with the client-side filter it drives, and the year under release-section albums. The Network panel ran the full window height and never auto-opened; it is bounded below the tab strip now and opens unless the content is constrained, as Slint does. The action circles were the wrong size with the wrong glyph colour and no overlay arm — which would have left them dark-on-dark once the gradient landed. **Quality in track rows.** The column rendered a bare tier; it carries the badge now, in the one shared row component every surface mounts. playlist_qt was the only producer not emitting the bit-depth line. **Skeletons hand over instead of disappearing.** They cleared when the PATH arrived, but the image still had to load and rasterize after that, so the placeholder vanished and left an empty cell. They now wait for the image to be ready. **Local covers.** Most local covers ARE already the scanner's thumbnail, but re-hashing their path produced a different filename, so each one was decoded, resized to its own size and re-encoded into a duplicate before being shown. That was most of the wait. cc |
||
|
|
f3a33245b2 |
feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before publishing anything, so the tracklist waited on an external service plus MusicBrainz. It publishes in stages now — header and tracks first, then each Qobuz rail as it lands, then Last.fm — with every patch merged under a generation counter so a late reply for album A cannot paint onto album B. The artist page was already staged this way; its views were the part still showing bare "Loading…" text. **Skeletons as the standard.** One control, new variants rather than new components, mounted across the views. Its second job is the one the owner named: cleanup. A cover that never resolves settles the placeholder once and reveals the card's own empty state instead of shimmering forever — local albums with no embedded art produce no artwork entry at all, so a placeholder gated only on "not arrived yet" would never stop. **Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid was one batch emitted after the whole window resolved. Each cover emits as it resolves now, visible band before prefetch margin. **Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke where the document carries it, the surrounding-line dimming, the controls flyout and the translation layer the v10 backend already supported. Exactly one row subscribes to the 30 Hz output; the timer drops to 250ms when paused or hidden. **Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal swipe, matching #653 — including the part that matters, which is that a swipe over a carousel scrolls the carousel. The layer is declared first in the shell so everything else gets first refusal. **Context menus.** Brought to the Slint inventory, Play later included, with right-click opening the same menu as the button everywhere. Entries whose seam does not exist are hidden rather than shipped inert. Two controls that rendered and no-opped are now honestly disabled: the My QBZ nav row, which routes to a view the shell has no case for, and three sidebar menu entries with no backing call. The sidebar caret was never a condition bug. The row is a positioner and the name cell's width ignored the count label, so a folder WITH content pushed the chevron past the clip — which is why it vanished exactly when it mattered. cc |
||
|
|
2b31d3866c |
fix(qbz-qt): clear a detail view's document when the next one opens
Opening album B after album A showed A's contents until B's fetch landed. The open path raised the loading flag but left the previous document in place, so the view had stale data to render and dutifully rendered it — and the wait grew when the album fetch gained the Last.fm row. Album, artist, playlist and the local album view now clear in the SAME hop that raises the flag. That is the half of loading state the port was missing: a placeholder over stale content is worse than one over nothing, because the user cannot tell it is stale. cc |
||
|
|
83999fc552 |
feat(qbz-qt): rebuild Settings 1:1 against Slint
The pane was written fast and had drifted both ways: four whole sections were a placeholder reading "not ported to the Qt frontend yet", and what was there carried rows and copy Slint does not have — an invented custom-theme info row, made-up "Restart to apply" descriptions, dividers Integrations never had, and two buttons forked inline instead of using the shared control. Every section is real now, in Slint's order and grouping, with Slint's labels and control types: Audio, Playback, Appearance, Offline, Local Library, Blacklist, Integrations, Developer, and the sandbox section that only appears on a Flatpak or Snap install. Local Library brings the folder table and the Plex panel — 856 lines of Slint with no counterpart here until now — wired to the Plex store that landed earlier this round. Its PIN auth flow is not ported, so the manual server-url and token path is what this exposes. The rule the round was run under: a row whose key is not in settings_qt's dispatch renders and does nothing, so it is either wired or not shipped. "Share logs" was exactly that — present since the port began, inert — and now opens the on-disk log. The directory is per-section files under a thin composition root rather than one growing file, and the sub-nav's bottom spacer stops being arithmetic over a hardcoded row count, which broke the moment anyone added a section. cc |
||
|
|
75ea846768 |
fix(qbz-qt): the app menu carries actions again, not settings
Intelligent search, Ambient background and Use system title bar were sitting in the hamburger menu. They are settings, not app-menu actions, and all three already have their real rows in Settings > Appearance — which is where Slint keeps them — so the menu was duplicating them in the wrong place. Documentation is added; it is the one Slint row that needed no surface the port lacks, just the external-link invokable QbzShell now has. Still missing against Slint, each waiting on a surface that does not exist here yet: Open Music Link, Keyboard Shortcuts, Report an Issue, What's New, About QBZ. They are named in the file header rather than shipped as rows that do nothing. build.rs now watches qml/ for changes. Cargo reruns a build script only when a source file changes, so dropping the new icon into qml/assets/ produced a binary whose qrc did not contain it — the file was on disk and the app logged "Cannot open" at runtime. That was going to bite every future asset. cc |
||
|
|
d76f694781 |
feat(qbz-qt): close the two declared debts on the album and artist pages
The Last.fm similar-albums row exists now. The recommendation building was already frontend-agnostic in qbz-external-reco, so this only adapts QbzCore to its catalog trait and publishes the result; the row seeds on the album's artist and excludes what the two Qobuz rows already show, including ids the pre-resolution dedup misses. Resolved rows cache per album for thirty days in the same store the Discover tab uses, so re-opening an album makes no Last.fm or Qobuz calls, and only a non-empty result is cached — a transient failure must not hide the row until restart. With Last.fm not connected it resolves to nothing and makes no network call, so the rail simply never appears. Relationship rows navigate. They carry a name rather than a catalog id, so the click resolves through MusicBrainz first and only a CONFIRMED match opens an artist — landing the user on a same-name musician is worse than a row that does nothing. The role travels with the name so a match in another role is not mistaken for this one. cc |
||
|
|
960715c4ac |
feat(qbz-qt): skeleton placeholders for the heavy views
Library and Home showed grey squares that filled in unevenly, which reads as broken rather than as loading. One shared QbzSkeleton — a card shape and a row shape — now stands in while data is on its way, and each placeholder clears as its own item arrives rather than all at once, so loading reads as progressive. The animation is the part that could have undone the point of this track, so it is capped: a bounded number of placeholders breathe, and the timer stops when the view is not visible or the window is minimized. Not on focus loss — a tiling desktop keeps windows visible and unfocused, which is the rule already established for the ambient field. cc |
||
|
|
49ab5f5b9d |
feat(qbz-qt): fill the Album and Artist pages from the integrations
The two pages were missing most of what Slint shows. With MusicBrainz, Last.fm and ListenBrainz wired this round, the sections that depend on them can exist: the artist biography and network, relationship rows, listener figures and the credit blocks the Slint pages carry. Nothing is faked. A section whose data an integration cannot supply is absent rather than an empty frame, and a section fed by an integration the user has not connected degrades to absent without a network call — integrations stay strictly opt-in. The relationship rows are non-navigable for now because resolving a musician name to a catalog artist needs a bridge invokable that is not there yet; an informational row is better than a dead click. cc |
||
|
|
e0fcf7a089 |
fix(qbz-qt): local context menus, collapsing search fields, toolbar density
Four things the owner hit in Local Library. The context menu did nothing there — local rows and cards now mount the shared menu the rest of the app uses, with the action set Slint gives local and Plex items. Anything with no seam in this port (add-to-playlist, add-to-mixtape, the tag editor, local favourites) is hidden or visibly disabled rather than a row that silently does nothing. The per-view search fields did not open and collapse the way the header's does. That behaviour moved into the shared QbzLineEdit, so the Local toolbar, the queue and the tree rail all get it from one place instead of four copies. The queue's field also escaped its 300px column; it is width-bound now. Filter, sorting and grouping become icon-only buttons with an explanatory tooltip, which is what the owner asked for and what the crowding called for. Found on the way, and affecting Qobuz cards too: the album card's ⋯ menu had no "Play later" at all, and its "Add to queue" row dispatched play-later — the row lied about what it did. Both fixed, with Slint's icon set for the three. cc |
||
|
|
c63889b487 |
feat(qbz-qt): Track Info in the now-playing bars, and PlayerBar split up
The info button on both bars did nothing. The modal is ported from Slint, and the document behind it — title, album, artist, duration, quality, ISRC, label, copyright and the grouped credits — comes from a new track_info_qt module. Credit parsing and its role localization were already frontend-agnostic in qbz-qobuz, so this only fetches and shapes; it does not re-implement them. A local or Plex id is rejected before the request rather than sent to the Qobuz catalog API, which is how a local album path ended up as a 404 in the owner's log. PlayerBar was 708 lines and still carried an inline SongCard, transport cluster and favourite toggle; those are their own files now, so the bar leaves this change smaller than it arrived. The volume controls in both bars are wired to the lock from the previous commit. cc |
||
|
|
c8a5b03aee |
fix(qbz-qt): live output LEDs, closer stamp, and a volume lock on ALSA direct
The LEDs only changed when the user navigated. They were published from the settings snapshot, so they refreshed when that document republished rather than when playback started — which is exactly when the backend and mode are decided, a moment after "now playing" in the log. They publish on the stream edge now, without polling and without republishing settings. The stamp itself was close but not 1:1, so it went back against shell/AudioStamp.slint, primitives/QualityBadgeFull.slint and shell/SongCard.slint line by line; the divergences in sizes, weights, spacing and the inactive LED colour are corrected. Volume is locked while the bit-perfect ALSA direct path is engaged. Software volume there is meaningless and applying it would not be bit-perfect, so the control now reads as inert rather than silently doing nothing. Slint has the same gap and no lock to copy, so this is new: the condition comes from the output state already published, and no audio behaviour changed — only the UI reflects it. cc |
||
|
|
4690c842b0 |
fix(qbz-qt): resolve artwork by source instead of GET-ing everything
Local and Plex covers never reached the now-playing bar or the queue. download_missing HTTP-GET every url it was handed, so two whole classes died before leaving the process: a file:// thumbnail that is already on disk cannot be turned into a request at all, and a Plex path like /library/metadata/49856/ thumb/… has no scheme. Both surfaced as "builder error" in the log. There is one url taxonomy now — http(s), local file, bare absolute path, Plex relative, unconfigured Plex, empty — and every consumer routes through it. Plex paths are tokenized by the same builder the Local Library grid already used, so the grid, the queue and the bar can no longer drift, and they share one cache entry per cover because the transcode size is process-wide. Also the reason a revisit showed grey squares filling in unevenly: the "already on disk" path was never synchronous. Every visible card called the cache service once per window pass on the GUI thread, and that call is a WRITE — it stamps last_accessed on the shared SQLite connection behind the same mutex the sixteen downloaders hold. Dozens of write transactions queued behind the download pool, so covers landed in lumps. A resolved-path memo makes a repeat lookup a read lock and one stat, and entries drop when the file is gone so it cannot serve a dead path. Two smaller ones found on the way: a non-2xx body was being stored AS the cover, which cached a grey square permanently, and file urls were not percent-encoded, so an album folder with # or ? in its name silently resolved to nothing. cc |
||
|
|
b580fdafad |
fix(qbz-qt): rasterize every Canvas on the GUI thread
The owner hit a segfault after clicking around. Cooperative and Threaded rasterize on the scene-graph RENDER thread, and RoundedImage is instantiated in 25 files — every card, row and queue row — so list recycling creates and destroys hundreds of these while async artwork callbacks schedule repaints on them. The render thread can be mid-draw on a pixmap whose Canvas is already gone. The ambient field makes it worse by repainting 30 times a second for the whole window lifetime on that same thread. Immediate keeps paint and destruction on one thread. renderTarget: Image already meant CPU raster, so nothing looks different. Not yet confirmed on hardware: the decisive check is running the previous binary with QSG_RENDER_LOOP=basic, which gives Cooperative no separate thread to race with. If that survives the repro and the default loop does not, this is the cause. cc |
||
|
|
31b1484c01 |
feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line file. Slint's is 2,671 lines across five tabs, and the local album detail is a routed page of its own. This closes that gap and splits the result: the view becomes a composition root over qml/views/local/ modules, and local_library_qt.rs becomes a narrow facade over local_* modules by concern. Plex is wired for real. The port hardcoded the cache path to None, so the union was local-only and Plex thumbnails were skipped. The settings store binds per user at session activation, the album and track union includes Plex, thumbnails resolve through the artwork path, rows carry their source badge, and the manual re-sync is on the toolbar behind the availability gate — as in Slint. Tabs: Albums with its quality/format/source filter popup, Tracks paginated, Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail. Every list is windowed from the start, artwork id-keyed and evicted, because 16k tracks is exactly where the Slint build freezes. The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral folder" pane plays music that was never indexed, and the album page gets its version picker — a local album with two physical copies now switches between them without a database round-trip. Two agents built the QML and the Rust halves in parallel and the QML called thirty QbzLocal members that did not exist. QML resolves lazily, so each would have been a control that renders and does nothing; a static cross-reference caught them before a build ever ran, and they are implemented here. Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and the tag editor have no picker or modal in this port yet, and local favourites are not wired, so those actions log rather than route a local id into the Qobuz API. Plex playback downloads the part instead of range-streaming it, which is Slint's own documented fallback. cc |
||
|
|
ade156a72a |
refactor(qbz-qt): split QbzAlbum and QbzArtist out of the God-object
Two singletons rather than one merged object, per the owner: each maps to the view it backs. Registering them and deleting the duplicates from bridge.rs is one change, because wiring one without removing the other compiles fine and leaves two independent states with the view frozen and nothing in the log. view_param_id goes with them. It had no consumer in QML, verified, and none in Rust once the two opens stopped writing it — so it is deleted rather than carried into a new bridge for symmetry's sake. bridge.rs is down to three domains: settings, search and playlist. cc |
||
|
|
e8a821dd9e |
feat(qbz-qt): the quality stamp, ported from Slint per bar mode
The port had one hand-made badge taken from the archived Tauri tree. Slint has three distinct components and each bar mounts a specific one, so the wrong tree had cost the stamp its LEDs entirely — they exist only in Slint, derived from the output backend and mode, and no amount of polishing the Svelte port would have produced them. QualityBadge becomes Slint's icon-only mark with its hover tooltip, for cards and rows. QualityBadgeFull is the icon plus stacked tier and detail. AudioStamp is the inline two-row form that carries the LEDs: backend blue when engaged, mode green, purple for a cast session, amber with the renderer name for a peer Qobuz Connect. New and Classic mount the SongCard stamp column, Large and Small the inline stamp, matching the .slint mount sites. The downgrade case follows Slint too: row one reports what is actually being delivered with an amber arrow, and the catalog maximum moves into the tooltip next to the reason it was limited. PlayerBar's LEDs stop reading the settings document, which never knew whether a backend was engaged, and read the now-playing output state instead — where Slint reads them. cc |
||
|
|
942f408fe1 |
feat(qbz-qt): publish the now-playing quality and output state
The bar had two quality fields and no way to say what was actually being delivered, so the stamp had nothing to render and the two output LEDs did not exist at all. quality_state.rs is a verbatim port of the Slint downgrade arithmetic — the 0.9 rate-family guard with its DSD exemption, the limit-cause classification and the delivered-tier resolution — kept pure and unit-tested, with the per-track catalog maximum seeded once on the track edge rather than per tick. output_labels.rs ports the backend/mode label derivation from qbz/src/settings.rs; it only reads AudioSettings and never touches the audio path. Fourteen properties join QbzPlayer: the delivered-vs-catalog block, the two LEDs with their active flags, and the cast/remote quartet. They refresh from where they actually change — the labels follow the settings, the downgrade follows the stream, and set_effective_stream dedupes so a steady stream costs no Qt-thread hop. Removes publish_with_cache, a near-copy of publish that was the reason the cache overlay and the quality fields could disagree. There is one publisher now. cc |
||
|
|
4b34b70f70 |
perf(qbz-qt): interpolate the spectrum band instead of raising the FFT rate
The band moved at the producer's 30 Hz. Raising TARGET_FPS would have been the obvious knob, but it lives in qbz-audio and is shared with Slint, and the amplitudes are already EMA-smoothed in Rust to roughly 6 Hz — a faster producer would have bought CPU and no smoothness. The published frames are now replayed across the render frames that follow them, which is what Slint's dock gets from its per-bar 90ms animate. A late publish degrades to a hold instead of a jump, and the motion runs at the display's rate rather than the data's. Splits the strip out of the dock into SpectrumBand, so the dock file owns only the gating and the layout arithmetic. cc |
||
|
|
5c631000b7 |
refactor(qbz-qt): split QbzLibrary out of the God-object
library_bridge.rs has sat in the tree unreferenced since the phase-23 work stopped: no mod declaration, not in build.rs, zero call sites, and duplicating members that were still live in bridge.rs. It could not have been wired as it stood — its module was named qbz_library, which is ambiguous with the qbz-library crate this binary depends on, so it never compiled. The module is now qbz_library_bridge; the QML type name comes from the QObject, so QML is unaffected. Registering it and deleting the duplicates had to happen together. Wiring it alone would have compiled cleanly and produced two independent Library states — QML reading one object while Rust published to the other — with the view simply frozen and nothing in the log. Moved with it: four properties, five invokables, three signals, the seven library-only ui() hops in main.rs, and thirty-four QML references. The Connections blocks in LibraryView, Sidebar, QueuePanel, AlbumView and PlaylistCollage retarget wholesale because every handler in them is a library signal. ArtistView's does not: it mixes onLibraryArtworkReady with onReleaseSectionReady, so it becomes two blocks — retargeting it whole would have orphaned the releases pager, and QML resolves handlers lazily enough that the discography would just have stopped loading with nothing in the log. bridge.rs is down to 389 lines and five domains. cc |
||
|
|
0799d59e01 |
feat(qbz-qt): Local Library — folder tree, windowed album grid, detail
The sidebar has had a "Local Library" row since the shell was ported and nothing behind it: the nav entry did not even navigate. This builds the view. QbzLocal is its own domain bridge (the phase-23 pattern, one #[cxx_qt::bridge] per domain) over local_library_qt, which consumes qbz-library for the scanning, the folder-vs-tag album identity and the queries rather than reimplementing any of it. The view carries the folder tree, the album grid, album detail, the track tabs, sorting and search, plus the scanning and empty states. Windowed from the start, not as a later pass: real local libraries reach 16k tracks and that is exactly the surface where the Slint port freezes. Artwork is id-keyed and evicted with the window. Playback is source-aware — a local track plays from disk through the player's play_data seam, because the Qobuz tier-walk needs a client and fails on every local advance. TrackRow gains an opt-out for its drag source: a local row's id is a local database row, and the sidebar's drop handler forwards whatever it receives to `playlist add-tracks` as a Qobuz catalog id, so dragging one onto a playlist would have silently added an unrelated track. Not included, and tracked as follow-ups: Plex union and thumbnails, multi-select and the bulk bars, ephemeral folders, the tag editor, A-Z jump strips, the group-by dropdown, the tree's resize handle, per-tab scroll restore, and local favourites on rows. Album detail renders inside the view rather than as its own routed page. cc |
||
|
|
cd1f4486b7 |
fix(qbz-qt): drive the FFT drain by signal instead of a second clock
The band animated like slow motion. The producer runs its own 30fps clock and the drain slept a fixed 33ms after each publish, so two independent clocks beat against each other and the drain intermittently missed a frame. The sink now unparks the drain right after latching a frame of the stream the visible mode consumes, and the drain parks again once it has published: exactly one publish per produced frame, no aliasing. It is also cheaper than the poll it replaces — zero idle wakeups instead of thirty a second — and an unpark that arrives before the park is remembered, so the latch/park race cannot drop a wake. Frames for the other two modes are still latched (not signalled) so switching mode has data on the first frame. cc |
||
|
|
1916d8d794 |
feat(qbz-qt): wire every integration to its real backend
The Integrations pane rendered rows that did nothing: Last.fm, ListenBrainz, MusicBrainz and Discord all had prefs but no runtime behind them, and Discord never pushed a presence at all. Each row now drives the qbz-integrations APIs for real — connect/disconnect state, the account label once connected, and the per-service toggles — and the runtime starts on shell entry, applying the persisted opt-ins and the scrobble queue's flush watcher. Scrobbles arm on the de-duped track edge (firing from the republish path would re-arm the timer on every poll tick) and the Discord presence refreshes on both the track and the play/pause edges, then clears on logout so a signed-out session stops advertising what was playing. All of it stays strictly opt-in: nothing here does anything until the user connects the service. cc |
||
|
|
f44f03e414 |
feat(qbz-qt): now-playing bar buttons 1:1 with the Tauri original
Both bars are brought to the reference's control set, order, geometry and hover treatment, and the bar's quality readout now mounts QualityBadge in its compact mode instead of a hand-drawn stamp. settings_qt publishes `normalization` in the settings document. It was already settable through settingsBool but never read back, so both bars had to shadow the toggle in local state and would drift from the real value after any change made elsewhere. cc |
||
|
|
10461bb49c |
fix(qbz-qt): playlist artwork — own graphic for Qobuz, collage for the rest
Two bugs with one root: the card bound the playlist's `images*` lists, which are MEMBER-ALBUM covers, not the playlist's own artwork. So a Qobuz editorial playlist showed one of its albums' sleeves, and a user playlist — which has no `images*` at all — showed nothing. Qobuz playlists carry their graphic in `image_rectangle`, which the model did not deserialize; the field is added (additive, `serde(default)`, and Playlist is only ever built through serde) and the card prefers it, contain-fitted so the artwork is not cropped. Playlists without one fall back to the member-cover mosaic, reusing the collage the sidebar already draws rather than a second implementation. Both the All-tab grid and its list mode are covered — the 44px list thumb binds the same map and would otherwise have gone blank. cc |
||
|
|
9618bfe137 |
feat(qbz-qt): give LabelCard its artwork and a hover action
The card rendered neither an image nor any hover affordance. Root cause of the missing artwork: the logo was gated on `item.imageUrl` while its source came from the host-resolved artwork map, so any mount that resolves covers into a side map without republishing the item drew nothing. Artwork is now driven by the resolved source alone and `imageUrl` only chooses the placeholder arm. The container is round, per the owner, matching ArtistCard rather than the square art the Slint reference uses; the contain-fit box is inset so a wide wordmark is not clipped by the circle, keeping the reference's "label logos are never cropped" rule. The hover row carries a single disc: labels are opened, not played, and the Slint reference offers exactly one label action. cc |
||
|
|
6e81b1ac78 |
fix(qbz-qt): forward the overlay button's click, and use it in AlbumCard
CardOverlayButton declared `signal clicked()` with no argument, but three
cards write `onClicked: function (mouse) { menu.openAtCursor(btn, mouse.x,
mouse.y) }` against it — so `mouse` was undefined and the context menus on
TrackCard, PlaylistCard and ArtistCard threw instead of opening. The signal
now carries the event; handlers that ignore it are unaffected.
AlbumCard drew its three hover discs as raw Rectangle + QbzIcon + MouseArea
blocks instead of the shared control every other card uses, which is how its
geometry drifted from theirs in the first place. It now mounts
CardOverlayButton with identical behaviour. One deliberate change rides along:
the favorited heart goes from the red `favorite` tint to the accent tint,
which is what discover/AlbumCard.slint actually specifies — the red was the
drift, not the parity.
cc
|
||
|
|
2875063626 |
fix(qbz-qt): one centred hover-action row for every card family
All four card families hand-rolled the same overlay row and all four were misaligned, each differently. They centred the group with a manual spacer Item computed as (cardWidth - 44 - 2*36 - 2*12) / 2 — the leftover once you account for the primary disc, the two ghost discs and the gaps between them. AlbumCard set spacing: 12 AND kept the spacer, so the spacer itself took a 12px gap and the group sat 12px right of centre. ArtistCard, PlaylistCard and TrackCard never set spacing at all, so the gaps the formula had already subtracted never materialised: the discs touched each other and the group sat 12px left of centre. ArtistCard also measured against 190px while its art is 200 wide. The arithmetic was never needed. CardOverlayRow centres a Row on its parent, which computes its own width from whatever children it has — so a card that hides a button (artists without the follow action) stays centred for free, where the old formula needed a conditional term to keep up. cc |
||
|
|
71c2356f7b |
feat(qbz-qt): port the quality badge 1:1 from the Tauri original
The port had reinvented this badge: an icon-only replica with no MP3 tier, no full or compact form, and none of the playback states. QualityBadge.svelte and QualityBadgeStatic.svelte in the archived Tauri tree are the reference, and every number here comes from their CSS. Four modes, the union of both originals: full (the 136x36 pill with tier row and bit-rate), compact (the narrow-bar "24/96" form from issue #303), bare (full's content without the pill chrome, for toolbars) and iconOnly (the Hi-Res brand mark alone, or a framed 30px square for CD/MP3 so both line up in a grid). Tier resolution follows the original's order, MP3 first so it can never come out as CD. The four playback states come with it: system resampling, ALSA plughw fallback, deliberate quality adjustment and confirmed bit-perfect, each with its own border hue and indicator glyph, plus the rich hover tooltip carrying source, output, the reason and the fix. Rows that only advertise stored quality leave the flags false, which is exactly what QualityBadgeStatic was. QualityMini becomes an alias over iconOnly mode rather than a second drawing of the same thing, so the six existing call sites keep working while there is one badge again. Brings the assets the port was missing: cd.svg, mp3.svg and the LINE Seed JP face the badge is set in. Their tint is baked to #b3b3b3 because Tauri applied it with a CSS filter and QML has none on the software path. Adds theme.alphaTier(pct) so the badge names the alpha ramp the design specifies instead of a raw index. build.rs now collects the whole asset tree recursively — the hand-written root list meant a new asset compiled clean and failed at runtime with "Cannot open". 18/18 tests; offscreen smoke clean. cc |
||
|
|
aabde3be63 |
fix(qbz-qt): unresolvable QML imports, focus-based freezing, and dock render cost
Three views instantiated components the phase-23 directory move had left unimportable: PlaylistView and SearchView use TrackRow (now in rows/) and SectionRail uses AlbumCard (now in cards/). QML resolves lazily, so these survived cargo check, the test suite and the offscreen smoke — they only failed when the user opened a playlist. Adds a repo-wide audit of every instantiated component against the importing file's visible directories; the three above were all of them. The ambient field and the new dock band froze on lost focus. That is wrong on any tiling or mosaic desktop, where windows stay fully visible while unfocused — the field would stop while the user is looking at it. Both now gate on the window not being minimized or hidden, which is the real "nobody can see this". Freezing on not-visible stays. Dock band render cost, in descending order of impact: the drain publishes only the stream the active mode consumes (Bars mode was marshalling a 512-float waveform QList 30 times a second for nothing); each mode marshals its QList into JS once per tick instead of once per delegate; one shared Gradient replaces 28 per-delegate instances; and the per-bar Behaviors are gone since qbz-audio already smooths and clamps the amplitudes upstream. 18/18 tests; offscreen smoke clean; component-resolution audit clean. cc |
||
|
|
70bd337d16 |
feat(qbz-qt): Large NPB dock — sidebar space reservation + FFT spectrum band
The Large dock was a bare cover overlay: the sidebar knew nothing about it, so the playlist rows kept running to the sidebar's bottom edge and sat underneath the album art. Sidebar.slint reserves the in-sidebar portion of the dock; the port had dropped that. Reserve it the same way, from a single source of truth. QbzShell gains largeDockHeight (shell_bridge.rs owns the arithmetic: 9 top + band + gap + 208 art + 4 bottom), the sidebar subtracts largeDockHeight - npbLargeHeight from the playlist list, and AppShell pins the overlay at height - largeDockHeight. The 112px bar height becomes a theme token so the shell and the reservation cannot drift apart. The band the cover was missing needs the FFT tap, so wire it: the runtime is built with_visualizer, viz_qt.rs runs the frontend-agnostic producer from qbz-audio and drains it at 30fps into a new QbzViz domain bridge as typed QList<f32>. Bars (28 mirrored columns over the 14 active bins), Waveform (48 downsampled L columns) and Energy (5 bands) render as Slint does, the cover's eye button toggles the band, clicking it cycles the mode, and both persist to ui_prefs. Cost is zero when unused: the tap starts disabled, the eye toggle gates capture, the drain thread parks while off, and the transport is mirrored so a paused player parks the producer instead of re-analyzing a stale buffer. The QML binds bar HEIGHTS against static-model Repeaters — the lists are replaced per frame, so a model binding would rebuild every delegate 30 times a second. Adds the eye/eye-off icon pair to the seven baked tint variants. 18/18 tests; offscreen smoke clean (0 QML errors, home published). cc |
||
|
|
9a4fec6b70 |
refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges
Two fronts of the modularization, both gated green before this commit. QML leaves the flat qml/ directory for seven modules: cards, controls, rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml stay at the root. build.rs matches the tree exactly (55 files), relative imports and asset paths were re-anchored for the new depth. The QbzBridge God-object starts breaking up: QbzSession, QbzShell, QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge] singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted in order from Main.qml. Bridge files stay directly under src/ because cxx-qt-build only accepts rust_files from one directory (QTBUG-93443). bridge.rs keeps 5.5 domains (library, album/artist, settings, integrations, search, playlist) and dies once they are split out. cc |
||
|
|
580b72084f |
refactor(qbz-qt): phase 22 — full QML duplication sweep beyond cards
TrackRow.qml unified from 4 copies (context arms, net -716 LOC with strictly more behavior); QbzSectionHeader, QbzTabBar, QbzNavButton, QbzIconButton, QbzCircleAction, QbzEmptyState, QbzOfflinePlaceholder consolidated; sort dropdowns onto QbzContextMenu; QualityMini routed everywhere. Deliberately separate per the Slint architecture: QueueRow, FeedListRow, underline tab strips, ReleaseSection header. Restored the offline 'Open Settings' action the POC had stubbed. km |
||
|
|
a19e2dac60 |
refactor(qbz-qt): phase 21 — one shared card component per family
Consolidated to the Slint architecture (one component per family with context arms, mounted by every surface): ArtistCard, PlaylistCard, TrackCard, LabelCard, SlimCard promoted to shared files; 7 duplicates deleted across HomeView/LibraryView/SearchView (net -1038/+93). Migration made several surfaces MORE Slint-true (ArtistGridCard 200x246 on all rails/heroes, real overlays+pin badges on Home/Pinned/ Search cards). SearchTrackHero kept distinct — it IS a distinct Slint component. CardMenu/CardOverlayButton/QualityMini promoted shared. km |
||
|
|
4dc7034e03 |
chore(qbz-qt): drop dead app_background_index helper
km |
||
|
|
245eaa4d45 |
feat(qbz-qt): phase 20 — live i18n across the 8 bundled locales
tr() gained a revision parameter (trRev qproperty bumps on switch) so all ~465 QML call sites re-evaluate — the Qt equivalent of Slint's reseed after select_bundled_translation(). Rust-built documents (home/library/settings option labels, plural tf() strings, active album/artist docs via record-free republish paths) re-publish on switch. Settings > Appearance language selector applies live (Auto + 8 locales, native names, shared ui_prefs.language key); boot honors the persisted pref (was unconditional resolve_auto). Verified es/ru/ja/ de mid-run with zero tofu (fontconfig covers CJK/Cyrillic); restart persistence verified; owner's en pref restored. km |
||
|
|
ea644f5e05 |
feat(qbz-qt): phase 19 — full Settings subnav, Appearance + Integrations, live theming
Theme system as data: qbz_theme registry (36 themes + auto/custom) resolved from the shared ui_prefs slug into a themeJson document; QbzTheme rebound to it so the whole app repaints live (OLED->Light probe-verified). Full subnav (8 sections + Share logs footer); Appearance 1:1 (theme dropdown with dark/light filter, ambient/titlebar rows in their real home, typography, library visuals, window chrome, tray via tray_settings.db, renderer); Integrations 1:1 (MusicBrainz, Last.fm 2-step browser connect + authed state, ListenBrainz token validate, Discord toggle -> live DiscordRpc.set_enabled). Offline/Local Library/Blacklist/Developer render honest placeholders. km |
||
|
|
c59c883ac1 |
feat(qbz-qt): phase 18 — all four NPB modes + ViewMode flyout
PlayerBar.qml covers New/Classic/Large (full-width seekbar with times, responsive 3-column layout, glass Classic SongCard variant, right cluster), NowPlayingBar seam Loader picks per mode; Small unchanged. ViewMode flyout wired (New/Classic/Small/Large live, Miniplayer/ Immersive/Kiosk inert replicas), mode persisted to the shared ui_prefs npb_mode key, Large forces the sidebar open and docks the 208px cover (SidebarNowPlayingDock equivalent); AppShell bar height mode-aware (42/112). Empty and playing states verified per mode; user's persisted small pref restored after probes. km |
||
|
|
8c1ff34205 |
feat(qbz-qt): phase 17 — PlaylistView 1:1 + drag & drop as Slint
Full playlist surface: header with owner/foreign gating, play/shuffle, favorite/follow optimistic, copy-to-library, pin, rename, delete, per-row remove for owners, search filter, full sort set, custom-order drag reorder persisted per user. Sidebar and every playlist card navigate; card overlay/menu actions un-stubbed. Drag & drop per the Slint gesture: row-body press-drag (>6px) from album/playlist/search/ library rows, window-top ghost pill, sidebar playlist rows drop-hot highlight, drop -> add_tracks_to_playlist + reload reconcile; in-list release reorders. Round-trip proven end-to-end on a throwaway playlist (create -> drag -> drop -> refetch shows track -> delete). km |
||
|
|
22e65e939b |
feat(qbz-qt): phase 16 — card overlays everywhere + window-centered modals
Overlay opacity bindings referenced a nonexistent parent chain — all card overlays evaluated invisible. Fixed per family: LibTrackCard (scrim + fav/play/more row, full Slint menu), LibArtistCard rewrite (circle-clipped scrim, play + more; play_artist with Popular-tracks first and studio-discography fallback), LibPlaylistCard overlay fixed, LibLabelCard verified overlay-less like the Slint. Modals: shared AppShell-level layer (full-window scrim, window-centered panel) replacing the view-local content-frame-centered ones. km |
||
|
|
9eb46d5e25 |
feat(qbz-qt): phase 15 — search 1:1 (cortinilla + full page with filters)
Intelligent search engine reused from qbz_app (SWR cache, interaction recording, rank_within, top_for_query; app-menu kill switch on the shared pref). Header field fully live: 220ms debounce, keyboard semantics (arrows/Enter/Esc), clear, idle-close. Cortinilla as a plain overlay (pointer stays on the field — Slint's exact reason): top result, spec-ordered sections with View more, skeleton, row activation dispatches (album/artist views, track play). Search page: 5 searchType filter radios (Main Artist/Performer/Composer/Label/Release Name), 5 scope tabs, Most-popular hero, carousels, per-type grids/lists with paged Load more. Perf: cortinilla 245ms cached / 1.1s cold. Fixed an artwork attach bug the screenshots exposed (url/path slot mixup). 18/18 tests. km |