13f1b106dc8b3609d6d54c138e5bf4afbf4e3ab6
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
13f1b106dc |
fix(qt): keep the Local Library select button clear of the search box
The expandable search keeps a 30px footprint and grows its inner field LEFT (QbzLineEdit.qml, `x: root.width - width`), so anything positioned before it in the row is covered while the search is open and cannot be clicked. On the Tracks tab the multi-select button led the row and was exactly that. Moved to the end of the group — the slot the Albums tab already puts it in, so the two tabs agree as well. Swept the other nine expandable searches: in all of them the field is already the first child of its row, and the tree rail's box width is computed to stop at the third button. Tracks was the only case. cc |
||
|
|
3bbe7a039d |
fix(qt): album info opens for every album, not only numeric ids
The guard had its predicate backwards. `album_id.parse::<u64>()` rejected
the four-in-five Qobuz albums whose id is alphanumeric ("vy32kidwrer67",
114 of the 139 ids in the captured fixtures) while admitting local library
row ids, which are exactly what it claimed to be blocking.
A rejected open returns before touching the bridge, so the modal kept
whatever document was there: the previous album's, or — once the QML
started holding its overlay until the ids matched — an empty card. Neither
said a word above debug level, which is the whole of "only the first album
info of a session works".
The published document now stamps `albumId` with the id that was ASKED
FOR rather than the one /album/get echoed back. The QML compares those two
with exact string equality, so any divergence blanked the card silently;
a mismatch now logs instead.
Regression tests cover the four real alphanumeric ids, the barcode-shaped
minority the old guard did accept, and the local/Plex keys the guard
exists for.
cc
|
||
|
|
7d98ad044f |
feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON document per screen plus a revision counter, so QML re-parses even when the text is byte-identical. - The list: two tabs with per-type counters, a server-side search on a 300 ms debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort, direction, two filters and grid/list; Tracks gets group and the filters only. Building the Albums toolbar on both would have invented two controls. - The detail: a format dropdown whose selection re-scopes every downloaded mark, the progress section and Add-to-Library together; a determinate bar counted in TRACKS, never bytes; goodies as an album-level action counted separately. - The album download is the frontend loop, not the Rust command the reference registered and never called. Sequential, concurrency 1, cancellable, and a failed track does not abort the album. - Both prefs are read at runtime, not merely written. `show_purchases` is the master gate and `nav_tb_purchases` only relocates the entry to the title bar; both default false, so Purchases still ships hidden. - Scanned local rows keep their raw `qobuz_purchase` source so the gold badge survives in list mode as well as grid, while still classifying under the Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping them would badge every purchase the user merely OWNS as locally downloaded. Defects fixed after the screens were written, none of which any test could have caught: - `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every publish is a silent no-op, so both screens would have stayed permanently empty with nothing logged. The cause was an ownership gap rather than the code: `Main.qml` belonged to no lane because the plan listed the files each lane CREATES rather than the files it TOUCHES, and "belongs to nobody" is indistinguishable from "done" until something runs. - A second download of the same album nested inside the first. The store rewrites its destination to the album subfolder after the first successful track so that Add-to-Library adds the album rather than the whole music root, and the download planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`, one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are now separate fields with names that cannot be confused, with a regression test. - A tab counter could stick at zero forever with its list plainly full: the two ids calls each swallow their error, and the reference's backfill from the list response was missing. - Cancelling made the whole progress block vanish instead of showing the cancelled state, whose msgid was already translated in all eight catalogs. - Add-to-Library re-walked the entire library when the folder id it needed was already in hand. - A live language switch and a logout left the previous state behind. Deliberate divergences from the contract, each recorded with its reason: the page header is a plain `fontSection` title with no icon tile, because nothing else in this application announces itself over a large coloured square; purchase download status travels inside the album document rather than on the shared cache channel, which is keyed by track id alone and would let the two features overwrite each other on the same row. Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p qbz-qt` 398 passing, offscreen smoke green with zero QML complaints. Not verified, and cannot be from here: every populated screen. This account owns nothing, so only the empty states have ever rendered. cc |
||
|
|
475f261dc1 |
feat(purchases): shared backend for the Qt port, with tags, covers and goodies
The frontend-agnostic half of Purchases. Ported against the Tauri reference, which is the only implementation ever validated by a real user; the Slint controller was read for divergences only and never as a source of truth. Context that sets the bar for all of it: Qobuz does not sell Purchases in this account's region, so no populated screen and no download can be exercised here. Anything merely plausible ships unseen. Hence the tests below are the deliverable as much as the code. Behaviour ported or corrected: - Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested tracks page (measured against a live account), so that predicate is unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded" can never hide anything. `get_downloaded_purchase_album_counts` answers it instead, giving a reader at last to the `album_id` column and to the index that has always sat on it. - `album_id` is written by every download path. It used to be set only by the album loop, so a release downloaded track by track produced rows the rule could never attribute. The `/track/get` payload each download already fetches carries the id. - `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual primary key; erasing every format is now a separate, named call. - `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate` panics off a char boundary — reachable because the mapping keeps Unicode alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where 200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The cap stays in bytes so no already-downloaded path moves. - The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both the reference and Qobuz's own desktop client, whose request helper takes a sign flag and passes it false for these and true only for `getFileUrl`. That signature is the entitlement proof and is untouched. - The purchase-type guard runs before any I/O; a bogus type no longer issues a live authenticated GET before failing. - Track subtitles survive. `PurchaseTrack` gained `version` and `build_purchase_album` maps it from the catalog track, closing a latent regression where every purchased track lost its edition. - Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are best-effort: the audio file is the deliverable and is already on disk and registered before any of it runs. - `goodies` is deserialized leniently. Its populated shape has never been observed — it comes back empty on every album nobody owns — and `/album/get` is parsed with the strict structs, so an unexpected shape would take the whole album down rather than merely hide a booklet. Riding along, both found while building the above and both unrelated to Purchases: - The metadata EDITOR silently discarded saves. `write_album_tags_to_files` picked its write target as "the primary tag, or else the first one I can find", and lofty refuses to WRITE a tag type a container only supports READING — silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no Vorbis comments, so every edited field went into that ID3v2 tag, the save dropped the lot, and the call returned Ok. The user pressed Save, saw a success toast, and the file never changed. Same defect, same fix, in the new purchase tagger. - `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its buffer from the response's own Content-Length and sets no total timeout, which is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on a machine that hard-freezes under memory pressure. Tests: 277 across the four crates, covering every acceptance item the contract set. The signature preimage character for character, the pagination loop including the zero-total case, the sanitizer against the inputs that used to panic, every deserializer default, and the wire envelopes against real bytes captured from a live account. A download harness stands up a local HTTP server and drives the pipeline end to end — real bytes over a real socket, a real FLAC tagged and read back, a real database — and the two tag regressions were both verified to FAIL against the old code before being accepted. cc |
||
|
|
9c67be4dcd |
fix(qobuz): give the offline-gate tests a rustls crypto provider
Both `client::tests::offline_gate_*` failed on every run, not intermittently: reqwest is built with `rustls-tls-webpki-roots-no-provider`, so BUILDING a client panics with "No provider set" until a process-level CryptoProvider is installed. The application installs one at startup via `qbz_app::ensure_crypto_provider`, but a test binary has no startup, and this crate sits below `qbz-app` and cannot call it. Confirmed pre-existing by stashing the unrelated work and re-running: same two failures, same panic. Worth knowing beyond these two tests: the panic fires at client CONSTRUCTION, before any request and regardless of scheme, so it hits a plain `http://` URL too. Any future tool or test that reaches for the download path without an application around it will land on it. cc |
||
|
|
f701bcd4db |
fix(qt): size-aware now-playing artwork, up to the mega tier
Restored queues and recently_played carried whatever variant the building surface picked — 50px JPEGs measured in the wild — into the now-playing bar, MPRIS, Discord and the immersive big slots. The np feed rewrites sized Qobuz covers up to 600px; big slots (immersive panels, split, preview overlay) resolve per size bucket through npArtworkPathLarge with one re-resolve per tier crossed and never on shrink. Registry-miss arm (restored sessions carry no ImageSet): rewrite to the FULL requested bucket with a session negative cache, so a pre-mega album pays one 404 per session instead of one per track change. And the mega tier's wire suffix is the literal _max.jpg, not _3000 (404 measured against the live CDN) — qobuz_cover_at_px now accepts and emits _max. Local covers gain an on-demand 1600px tier (never upscaled); Plex big slots tokenize at 1024. km |
||
|
|
e6c058c2f2 |
feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed by the Viz16 stream. Scene rows live in the header view menu (no Off row); g cycles the ring. A fresh immersive open defaults to Split > Lyrics (owner ruling). OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as type buf and type buf"): every ShaderEffect fragment with its own uniform layout now ships a paired vertex stage declaring the identical block — the shared spectrum.vert only ever linked under Vulkan. RHI hardening: render pass descriptors on the ping-pong targets (the Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the backend (!== OpenGL), ribbon sub-uploads carry setSourceSize. km |
||
|
|
8bd02e35d3 |
feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.
ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
no onClicked, and a RoundedImage with no fit:, so the default crop blew an
800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
reference already has (Add/Change/Remove image, Open in browser, Save as…) and
the lightbox this port invented for albums and playlists. Custom images are
keyed by artist NAME into the same custom_artwork.json the Slint build writes,
so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
artwork_qt::cached_path consults the override FIRST, so coverMap held the
custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
is not: it leaves the percent-escapes in place, so any path holding % # or ?
came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
"View cover", since en carries none of that family and falls back to the msgid.
CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
their expansion into the full 55-value palette and the on-disk format all stay
in the shared qbz-theme crate, so a theme authored in the shipping Slint build
already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
because current_slug() re-reads it from disk — a seed taken afterwards would
resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
memoized for the life of the process and filled at bridge construction, so a
process that booted without the file would keep serving the OLED default even
after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
every drag frame with a truncating write, on a file the other build has open.
ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
a section plus a TOC entry, bold and backticks are stripped rather than styled.
Text.MarkdownText is a different subset and would have rendered a different
document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
to bump.
DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
carries about seventy, so every issue filed from a Qt build arrived with no
kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
sample-rate state and no playback context. The report is injected INTO
qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
not mapped, so their runtime column can only read "—", and their saved column
would come from a graphics.json this binary never reads or writes — foreign
state presented as this app's configuration.
SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
on declaration order and carried no z, so any z-bearing modal buried them: a
toast raised by a background event behind an opaque panel is never seen. They
now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
always-dirty, so the whole build script — per-shader qsb, the recursive asset
sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.
Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.
Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/
cc
|
||
|
|
4bfd8b30e2 |
fix(library): keep a track's rowid when re-indexing it
insert_track used INSERT OR REPLACE. SQLite resolves that conflict by DELETING the old row and inserting a new one, which hands out a fresh rowid. playlist_local_tracks.local_track_id is a foreign key onto local_tracks(id), and foreign keys are OFF in this database (the only pragmas set are journal_mode and synchronous), so the cascade never fires: the playlist row survives pointing at an id that no longer exists and the INNER JOIN that reads it returns nothing. The scan re-inserts every file — there is no mtime skip — so every library rescan silently emptied Qobuz playlists of their local tracks. The conflict that fires is not the obvious one, which is worth recording: the table's UNIQUE(file_path, cue_start_secs) does NOT catch an ordinary track, because cue_start_secs is NULL and SQLite treats NULLs as distinct. What catches it is the partial index idx_tracks_file_nocue. Both identities are handled. The row is now looked up and UPDATEd in place. Reproduced on a scratch database before the fix was written, and the regression test was confirmed to fail against the old code path. First-class LOCAL playlists were never affected: local_playlist_tracks stores local_path, not an id. cc |
||
|
|
0bba3d0cf4 |
fix(library): stop the direct tag write from clobbering per-track ARTIST
write_album_tags_to_files called set_artist(album_artist) unconditionally on every file. Two irreversible outcomes, and a direct write has no undo: - On a various-artists release the editor pre-fills the artist field from compute_track_artist_match, which returns None the moment two track artists disagree — so the field renders EMPTY and that empty string was written over the ARTIST of every file in the release. - With a non-empty album artist, every guest credit was replaced by it. The DB half of the same save was already careful: update_album_group_metadata renames the track artist only where it still equals the value that was uniform across the album. The file writer now agrees with it. A pre-pass reads the prior artist from the files themselves — they are the ground truth about what is being overwritten — and should_rename_artist renames only on an exact match with a non-empty new value. ARTIST is track scope and the editor has no field for it. The pre-pass is skipped when the album artist is blank, so the common path costs nothing. cc |
||
|
|
8783031ea0 |
fix(library): group a titled disc folder into its release
In the DEFAULT (folder) grouping mode, a box set laid out the way our own
multi-disc guidance recommends — Disc NN - {subtitle}/ — showed up as one album
per disc.
album_root_dir climbs past a directory only when is_disc_folder accepts it, and
that predicate required the name to consist ONLY of disc tokens, digits and a
short modifier list. "Disc 01 - TV Series Soundtrack #01" tokenises to
["disc","01","tv","series","soundtrack","01"], has_extra_words hits "tv", and
the climb stops — so the group key becomes the disc directory and a 13-disc set
becomes 13 albums.
is_disc_folder now also accepts a name whose LEADING token is a disc designator
followed by a separator with whitespace on at least one side. That is disjoint
from the issue #147 false positives, which are album titles that END with a disc
token ("Now 75 - CD1", "100 Popular Classics, Disc 1", "Relaxation Disc1") —
their regression tests pass unchanged. The rule stays narrow deliberately:
"CD-Rom Favourites" and "Disc Jockey Anthems" are still album titles, because an
accidental merge of two releases is worse than the split being fixed.
The disc NUMBER is gated on the same predicate, so a box set with no DISCNUMBER
tag now recovers it from the folder name too.
Sidecar lookup follows the key, so moving the key would have orphaned every
.qbz.json saved while a disc was still its own album — album title, artist, year,
genre, catalog and all per-track overrides, silently. apply_sidecar_override now
tries the album root and then the file's own directory, and the CUE path goes
through the same function instead of its own single-directory copy.
Existing installs see the merge after a rescan; album_group_key is stored at
index time.
cc
|
||
|
|
8ff0adc149 |
fix(qt): convert the Slint colour literals that made scrims and shadows invisible
Slint writes #RRGGBBAA, Qt parses #AARRGGBB, so a literal copied verbatim does not come out the wrong colour — it comes out transparent, because the alpha byte is whatever the red byte used to be. Seven sites across five modals. Every faked 32px drop shadow in the port was #00000080 (alpha 00), and the scrims of LogViewerModal and LibFolderEditModal were #000000bf, so those two modals had been opening with no dark backdrop at all. The rest of the tree already spells the same scrim #bf000000. Found by grepping for 8-digit literals of the shape #00xxxxxx. The gradient stops in MixArtwork and LocalAlbumView that are legitimately transparent, and the comments in the scene components that warn about this exact trap, were checked and left alone. cc |
||
|
|
016eb3cd23 |
feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
|
||
|
|
b045f7023b |
chore(qt): vendor the country flag set used by the artist scene
265 SVG flags (1.1 MB) plus the upstream MIT licence and attribution in crates/qbz-qt/third-party/. Kept as its own commit so the asset drop can be rolled back or re-sourced without touching the scene code that consumes it. cc |
||
|
|
085c35876a |
revert(qt): make the content router synchronous again
An asynchronous Loader plus a router-level skeleton was meant to fix the dead click on navigation. It made the app markedly worse and is reverted whole. Async incubation is TIME-SLICED: it yields to the UI thread between slices, so it trades a short freeze for a much longer wall-clock build, and the Loader stays in Loading until the entire tree exists. For Discover > Home that is all 413 cards -- a one-second freeze became a five-second skeleton on the most used route in the app. Measured after the fact, which is the mistake; a change that moves WHEN something heavy is built has to be timed on the busiest route before it is called a fix. The placeholder is gone with it. It drew the same two text lines and row of five cards for all 40 routes, so the swap moved everything -- the exact jump a skeleton exists to prevent. The dead click is real and remains unfixed. The durable fix is not here: stop rebuilding these views on every navigation (keep the top-level tabs alive) and build the heavy views incrementally. The reasoning is kept in the file so the experiment is not re-run. cc |
||
|
|
fda56f9ea2 |
fix(qt): pin the artist jump nav bar and round it at the pane edge
The strip scrolled away with the body; the reference clamps it to the top. Lifted out of the flow with a slot holding its place, using the reference's clamp verbatim -- the formula must not add the viewport y or the scroll is double-counted and the bar lifts ahead of the body mid-transition. Pinned at the pane's top edge it then showed the square-corner defect already fixed in Discover: Qt's clip is a rectangular scissor that ignores radius, and the shell hides its bezel nubs while the dynamic background shows through the corners, so a full-bleed child has to round itself. Driven off the live y, so the rounding exists only while the bar is actually at the top -- rounded corners in mid-page would be a notch with nothing behind them. cc |
||
|
|
c2944db973 |
feat(qt): source-aware playback, session persistence and loading affordances
One batch, committed together because the changes interleave in main.rs and
splitting them after the fact would be a fiction.
Playback routing: a track now carries its source, so a local or Plex row played
from Discover stops being resolved against Qobuz first (the 404s and the long
silent wait before audio). Jellyfin and Navidrome land on the same seam.
Plex tracks stream through the player instead of downloading whole files first;
the feeder moved into qbz-player so both frontends can reach it.
Session persistence, both levels the port was missing:
- The startup-page preference is finally read. It had been rendering and
persisting a value nothing consumed. `last_view` is written in the SHARED
ui_prefs vocabulary, not this port's route ids -- four of the six differ,
and writing the raw ids would have silently broken the other frontend's
restore. `on_session_entered` recorded "home" unconditionally, which also
rewrote last_view on every boot, so the setting could not survive its own
startup.
- Queue and active track survive a restart. session_persist was tied to a
frontend by exactly one type alias, so it moved to qbz-app generic over the
adapter rather than being copied. Verified end to end: the snapshot restored
here had been written by the Slint binary, and the exit flush keeps an
un-consumed resume position instead of overwriting it with the engine's
idle 0.
Loading affordances: the transport shows a spinner from the moment a play is
dispatched, and the restored session explicitly takes it back down -- a
restored track has no dispatch behind it and no audio ahead of it, so nothing
would ever have cleared it. Cortinilla results now navigate at album and artist
level.
Verified by the offscreen smoke gate and by driving a restore/quit cycle over
MPRIS.
cc
|
||
|
|
3b03682e09 |
fix(qt): pane corners stay round under the dynamic background
AppShell hides its BezelCorner nubs while the field is meant to show
through the pane's corners, and Qt's clip is a rectangular scissor that
ignores radius — so any full-bleed child at y=0 squares the pane. The
doctrine was already written in AppShell.qml ("a full-bleed pane child
must round itself; there is no mask that can do it for it here"); these
eight surfaces predated it or, in the two thin bars added with the
ambient port, ignored it.
- The seven fixed 56px headers (Home, Search, DiscoverBrowse,
ArtistReleases, PlaylistBrowse, PlaylistManager, PlayHistory) take
topLeftRadius/topRightRadius. Invisible with the background off, where
the view root paints the same colour beneath. PlaylistBrowse's SECOND
bar is the category strip at mid-page and keeps square corners;
PlayHistory keeps its unconditional opaque fill, which is reference
parity (:43/:34) and a separate question from the corner.
- LocalAlbumView's neutral header band is now gated on the background,
1:1 with the Qobuz page's atmosphere (AlbumView headerAtmoOn,
AlbumPageView.slint:168): the two dark backdrops clash and the field
already provides one. That band had been square since it was written;
the nubs had been hiding it, and they only stand down when the field
is showing. Header text there is theme.textPrimary, not the Qobuz
page's light-on-dark hdrStrong, so it does not depend on the band to
stay readable — checked before removing the backdrop from under it.
Gate: cargo build -p qbz-qt, both QML audits, offscreen smoke 0
complaints.
cc
|
||
|
|
4e4decb1e2 |
docs(qt): release-2.1.0 placeholder with the GPU campaign note
The docs tree is not versioned, so the release-facing summary of the repaint-pulse campaign lives here as a draft TO-DO for the first Qt release, with a discreet pointer from the pulse property itself. km |
||
|
|
eb80e2f367 |
feat(qt): animated now-playing indicator in track rows
Restores the eq-bars indicator behind the existing play-indicator-animation pref (the toggle already lived in AppearanceSettings; the pref was plumbed but unconsumed). The bars ride the shared shell pulse, so they cost zero extra presents — the affordable version of what used to be a display-rate animation. Pref OFF keeps the static accent pill + ring, per TrackPlayCell.slint. km |
||
|
|
a2a99a72cc |
perf(qt): bring the immersive onto the pulse (83% -> 25% GPU)
- AlbumReactivePanel: drop the eight 100ms Behaviors on 30 Hz data (a retriggered Behavior animates at display rate and every animation frame is a full-window present), and read the settle — not the publishing bridge list — in the energy formulas (the .length guard re-evaluated the visual bindings on every FFT publish). - LyricsSyncEngine: the fast cadence rides the pulse; the 250 ms idle re-check keeps its own timer. - EqualizerBars: the phase accumulates on the pulse and the 'active' gate is host-provided (invisible subtrees still dirty the window). Measured on the owner's machine, immersive reactive panel open: 132 -> ~33 presents/s, 83% -> ~25% GPU. km |
||
|
|
f46c5c95d4 |
perf(qt): one repaint pulse for the whole shell (GPU 95% -> 25%)
Qt Quick has no partial redraws: any dirty item presents the WHOLE window, and on the owner's hybrid laptop each full-window present costs ~1.2% GPU flat (KWin composite + dGPU scanout; area-independent, while the app's own render is 0.08 ms/frame). The bill was presents/s, and the shell ran unsynchronised ~30 Hz clocks plus two leaks: - QbzShell.pulseMs: ONE Rust-driven 33 ms edge that every continuous animator ticks on (knob QBZ_PULSE_MS). VizSettle now stashes on publish and applies on the pulse (its 16 ms interpolation timer is gone); AmbientField and ImmersiveAtmosphere ride the same edge. - VizSettle writes NOTHING while not live: the always-mounted but invisible immersive panels were dirtying 28 bar rects per FFT publish (named via QT_LOGGING_RULES=qt.quick.dirty=true). - AmbientField renders inline again: ShaderEffectSource scheduleUpdate() double-presents (minimal repro fbo_repro.qml in the investigation folder). - Background mode 2 (ImmersiveAtmosphere) composites its four drifting layers + base + dim + scrim in ONE opaque ShaderEffect (atmosphere.frag, baked by build.rs); numeric parity vs the Image stack verified (atmo-shader-parity-check.py, delta 0.0). The four-Image stack stays as the software/failure fallback. Measured on the owner's machine: 93-97% -> 25-26% GPU, ~34 W -> 12-13 W, ~33 presents/s in both background modes. Full story: qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/GPU-COST-INVESTIGATION.md km |
||
|
|
b136c9cf5d |
feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc), uncommitted until now: the app-wide dynamic background in both modes (AmbientField with GPU/Canvas arms, AppShell layering, ambient palette pipeline), the wave-1 clip/batch sweep across the tree (alpha batches 449 -> 327, reproducible), the rounded-cover and icon-tint arms, and scripts/qt-batches.sh (batches + presents/s sampler). The GPU behaviour of these pieces was finalized in the follow-up pulse commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/ GPU-COST-INVESTIGATION.md for the full campaign. km |
||
|
|
21e941bdf6 |
feat(qt): renderer tier, GPU selection and a real shader build
Closes PARITY-DEBT #83 and #104, and unblocks the immersive shader scenes. THE TIER. Slint resolves one bool, use_gpu_renderer, and hands it to three features: the shader scenes, the dynamic background and half of reduce-motion. This port had none of it, which is why the scenes could not be offered — off the GPU tier they render black and nothing could tell. QbzShell now carries rendererApi, gpuTier, shaderScenesAvailable and appBackgroundAvailable, all written from one latch. Only QML can source it: QRhi picks the backend after QGuiApplication, so the pre-init code knows only what it asked for. Per-item GraphicsInfo.api stays where it is. "Can this item draw?" and "should this feature be offered?" are different questions, and routing the first through Rust would make every cover wait on a round trip. SHADERS ARE BUILT NOW. build.rs bakes every .frag/.vert to .qsb with ten variants and the batching rewrite. They used to be baked by hand and committed carrying no GLES variants at all, so any GLES context failed them with "No GLSL shader code found" — which is exactly how the NPB Large visualiser went blank while this branch was being tested. Adding a scene is now dropping a .frag in the folder. GPU SELECTION enumerates real devices through Vulkan and lists them by model, never by class: a machine with no discrete GPU can no longer be told to use one. Legacy class keys still resolve, but only against hardware that exists. Picking a non-default device implies the Vulkan backend, which the row's own description states in all eight locales — measured as the only route that keeps a working context: DRI_PRIME lands on llvmpipe and the NVIDIA PRIME pair lands on OpenGL ES. The renderer row gains the auto-revert sentinel and the frame-liveness watchdog it shipped without. The sentinel arms only for a choice that truly forces a backend, and liveness means frames rendered over a window, not merely a window that opened. Also fixes the kiosk toggle overwriting the tier half of reduce-motion. Verified by 312 tests, both QML audits, the offscreen smoke, and a probe against the real hybrid GPU: both devices enumerate by name and the stored preference applies. cc |
||
|
|
8ba9483462 |
feat(qt): HiFi Wizard — guided DAC setup ported from Slint
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened from Settings > Audio. It probes the audio stack and renders per-distro remediations, enumerates DACs with a manual node.name escape hatch, generates the PipeWire / WirePlumber snippets to copy, and plays four tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated read-back. Read-only: it never writes a system file or runs a command. The logic was unreachable from Qt — every public function in qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals. It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core, which returns plain data. qbz-dac-wizard itself is left untouched. Rust owns everything it computes plus `open`, so the reset and the probe are one act; QML owns the step navigation and the four progress checkboxes, which is how the reference splits them too. Two deliberate divergences from the reference: the row sits at the end of OUTPUT rather than BIT-PERFECT, and it is offered on every backend instead of PipeWire only — someone on ALSA direct is exactly who needs the walkthrough. The green glyphs use the accent tint, not success: success has no qrc bake directory, so a degraded tint set would render nothing at all. Every success-toned text keeps theme.success (#3fae6a verbatim). Verified by 309 tests, both QML audits and the offscreen smoke, plus a forced-route probe that mounts Settings and walks the wizard's steps — the standard smoke only mounts Home, so it is no evidence for a modal. The modal was also driven and screenshotted over VNC. cc |
||
|
|
79e3cdc46b |
feat(qt): multi-select bulk bar on every Qobuz track listing
The album-page pattern (selection in QML, actions in Rust) generalized: - New shared engine bulk_tracks_qt.rs: one catalog batch-resolve that preserves the selection's visible order, then queue / play-next / play-later / add-to-playlist / add-to-mixtape / add-to-favorites / make-offline, with the queue context stamped per host. - PlaylistView: toggle in the header action row, bar inline above the column header, selection resets when the playlist changes. - ArtistView: the Popular Tracks header's dimmed toggle goes live; one selection covers Popular Tracks + Appears On + the in-library rows; the PopularTrackRow fork gets the select checkbox AND its offline cell/menu block stop being inert stubs. - LabelView: Popular Tracks gets the same treatment. - AlbumView: the bar moves from a floating overlay to the inline spot the Local Library album view uses (bar in flow, content below). km |
||
|
|
d112c726b6 |
fix(qt): custom album covers propagate to every surface
The per-builder sweep was whack-a-mole — the report caught empty cards in Home > Recently Played Albums and the NPB, and mosaics still showing the Qobuz art. Overrides are keyed by album id but most surfaces carry only the art URL, so the link is now the artwork HASH: every size variant of one Qobuz cover shares its filename stem. Setting a cover records hash -> path in a Qt-side store (custom_cover_keys.json — same reason as the playlist store: the Slint file round-trips known keys only), the album page backfills it for covers set before the map existed, and artwork_qt's cached_path / cached_raw_path answer the override for any URL whose hash has one — cards, NPB, MPRIS, queue, collage tiles, no per-surface wiring. download_missing skips overridden URLs. km |
||
|
|
a53e8a7af7 |
feat(qt): custom covers for playlists + the app-wide custom-art sweep
Two halves of one feature: - Playlist custom covers (Qt-first; no Slint counterpart yet): the playlist header cover gets the cover menu (Add/Change/Remove) and the lightbox on left-click. The store is a NEW file, custom_playlist_covers.json — a `playlists` key inside the shared custom_artwork.json would be silently dropped the next time the Slint app wrote an override (its store round-trips only known keys). The override beats the playlist's own Qobuz graphic AND the member-cover mosaic on the header, the sidebar rows, the cards and the library grid. - Album custom covers now apply everywhere, not just the album header: doc builders route an album's art URL through cover_artwork_qt::prefer_album_cover (the override's path flows through the ordinary artwork pipeline — artwork_qt::classify's LocalFile arm resolves it synchronously, no QML change). Swept: album page carousels (more-from-artist / suggestions / Last.fm similar), artist page releases, search result cards, and the playlist mosaics (a member album with a custom cover contributes THAT art to the collage tiles). km |
||
|
|
83b8bd8890 |
feat(qt): offline downloads + multi-select on the album page
Two AlbumView parity features sharing the row machinery: OFFLINE CACHE (the menu rows and the per-track state the report was missing): - offline_qt.rs activates the per-user OfflineCacheState on login / session-restore / offline-session (one fan-out point in bind_per_user_stores) and tears it down on logout — same on-disk store as the other frontends, so copies made in Slint/Tauri show here and play here. - playback_qt's play funnel now passes the active cache + the row sink to play_track_resolved (it passed None, None: the offline tier was unreachable even for downloaded tracks). - offline_cache_qt.rs ports the action set: cache_track / cache_tracks / cache_album / redownload_album / remove_cached / refresh_cached, with toasts and a bridge-signal row sink (trackCacheStatusChanged). - TrackRow's download column is live: cloud / spinner / accent check / red alert per status (0/1/2/3/4), click map ready->remove else download, and the context menu's offline block (Make available offline / Refresh / Remove). Status is seeded per row from the session cached-id set (album/artist/label/for-you docs) and pushed live over the signal. - Album ⋯ menu: "Make available offline" swaps to "Refresh offline copy" on the live all-cached aggregate. MULTI-SELECT: the toolbar toggle is live; selection state stays in QML (LibraryView precedent), rows check off in select mode, the bulk bar carries the reference's full inventory — select-all, play-next, play-later, queue, add-to-playlist, add-to-mixtape/collection, Add to Library, make-offline, clear — and the Ctrl+A/Escape router duck-types the view's selectAll/exitMultiSelectMode like the other tabbed views. Still out (documented in place): the Offline Manager view, the row padlock on unlock, per-disc actions, playlist-level cache button. km |
||
|
|
704fd7e60a |
fix(qt): artist Latest-release card renders its data again
The card bound `artist.lastRelease.*` unqualified, and inside an AlbumCard instantiation the bare name `artist` resolves to the CARD'S OWN string property — a string has no .lastRelease, so every binding fell back to "" and the card rendered as an empty tile (section visible, no art, no title) while the grids' modelData-driven cards were fine. Caught live on a VNC-driven session: the published document carries the full lastRelease object; only the QML scope shadowing ate it. Every read in the block is now root.artist.lastRelease. km |
||
|
|
af95ce164a |
fix(qt): search top-result track hero no longer invades the Artists rail + crown icon
Two Most-popular regressions on the search results page: - When the top result is a TRACK, the hero painted from its slot's center ~100px into the Artists carousel: SearchTrackHero's root Rectangle had no size, and a bare Item's implicit size is 0x0 (it does not size from children), so the slot's horizontalCenter anchor pushed the whole card sideways. The artist/album arms carry explicit sizes and never shifted. The hero now declares its 200px width explicitly. - The header glyph was "award", which reads as a mic. The reference uses the crown (SearchResultsView.slint:584); crown.svg is ported into the icon masters (primary + the warning bake) and the header uses it. Both verified live on a VNC-driven session with the exact "all things" search from the report. km |
||
|
|
1fe20e9af0 |
fix(qt): album info modal never shows a previous album's document
Opening album A's info, closing, and opening B's left A's fetch in flight with nothing stopping it from publishing A's document over B's loading state — the modal rendered A's credits inside B's page (smoke catch, 2026-08-10). The carousel rows in album_qt.rs already carry the "late replies are generation-guarded" pattern; the modal now does too: a monotonic open counter gates every publish, open() clears the stale document with the loading flag, and the QML overlay additionally holds until the published document's albumId matches the requested one. km |
||
|
|
d9b1c26ff3 |
feat(qt): album page parity — cover menu + lightbox, header actions, album info modal
Four wired surfaces on AlbumView, all previously inert stubs: - Cover menu (right-click, AlbumPageView.slint:300-370): Add/Change/Remove custom cover over the SAME custom_artwork.json store the Slint app uses (a cover set in one app shows in both), Open in browser, Save as…. Plus the NEW lightbox (menu entry + left-click): the art at its best available quality fitted to the window, custom override preferred. - Header action row: radio (the existing foryou_qt album-radio seam), booklet (visible when a PDF goody exists; downloads via save-as — no in-app reader, like the reference), and add-to-mixtape/collection — the MyQBZ picker's Accepts engine already lets albums target Collections (the Tauri rule), so the button just feeds it. - Album Info modal (the extensive one): fresh /album/get → credits grouped through the shared qbz_qobuz::performers parser, per-track copyright, review tab when the album has editorial text, label link, musician navigation, per-track play in album context. Tauri layout (left fixed meta column, right scrollable tabs) per the project ruling for this feature; the header's label link is wired along the way (LabelView has existed for a while — the POC-NOTE was stale). - … menu: Add to playlist (the loaded view's track ids into the picker), Add to mixtape, Share Qobuz link, Share Album.link (UPC -> Deezer -> album.link, #514 path — the resolver half of share.rs is now ported for the album arm). The offline rows stay out: no Qt album-cache seam. i18n: "View cover" + "Could not load the image" catalogued in all seven locales. Gate: both QML audits + 305 tests + debug/release builds green. km |
||
|
|
2ca7b672e0 |
fix(qt): album carousel cards — artist fallback and similar-album genre
Two card-data gaps on the album page's bottom rails: - "Listening suggestions" (/album/suggest) rendered no artist line: the Qt mapping read only the flat `artist` field, while Slint's album_map::album_artist falls back to the artists[] array (main-artist role, else first entry) — the shape /album/suggest actually returns for some albums. The fallback is ported as card_artist. - "Similar albums" (Last.fm row) showed no genre in the hover overlay: AlbumReco carried no genre at all. build_album_reco has the resolving Qobuz Album in hand, so the field now exists (serde-defaulted, so cached reco JSON keeps working) and is mapped through. Slint's external_reco::album_card can map it too after the next merge — its checkout is a different branch and is untouched here. km |
||
|
|
98dfcd8173 |
fix(qt): nav flyout entries land on their own tab
Every flyout entry (Discover > For You, Library > Albums, Local > Folders...) navigated to the section but landed on its default tab: the port handed the tab to the mounted view through a depth-capped scene search (NavFlyout.findTabHost, cap 7), and the ContentRouter extraction (kiosk port D3) added a mount level the walk never reached — flagged as a latent desktop regression in the kiosk audit (2026-08-02 AUDIT-REPORTS.md, finding 11) and confirmed by smoke test. The tab now travels the way this codebase crosses documents: the bridge. NavFlyout.activate calls the new QbzShell.navigateToTab(view, tab), which publishes navTab/navTabView/navTabSeq, and a Binding in ContentRouter applies it to the mounted view's activeTab — the same pattern as the MyQBZ kind discriminator. The seq counter re-applies on a repeated click of the same entry, the navTabView guard keeps a cross-view request off the outgoing view, and a plain navigateTo clears the request so section navigation never drags a stale tab along. A click for the already-mounted view skips navigation (no duplicate history entry) and re-applies the tab live. Verified by smoke: cross-section, same-section, repeated-click and plain-navigation cases all land where Slint lands. km |
||
|
|
f24da6c784 |
i18n: catalog strings for the queue drop prompt, folder editor, Plex PIN, log viewer, export auth gate and offline recheck
cc |
||
|
|
6c8b63daaf |
fix(qt): settings reset no longer touches streaming quality
The reference leaves Streaming Quality out of settings resets on purpose (it is a UI-only pref owned by neither domain store); this port forced it back to hires_plus, silently re-enabling hi-res streaming for users on metered connections. cc |
||
|
|
479921af52 |
feat(qt): Offline settings 'Check now'
Hands out the connectivity actor's existing public request_recheck so the row probes immediately instead of waiting for the next scheduled check — the passthrough was the whole gap. cc |
||
|
|
eca07d4370 |
fix(qt): settings export honors the include-auth gate
The export row hard-coded include_auth=false, so the 'portable bundle' the UI described could never sign you in — the reference's single checkbox (default OFF) is now wired through, as session state rather than a persisted pref. cc |
||
|
|
c198afb33c |
feat(qt): queue drag-and-drop insert at position
Dragging rows onto the queue panel now lands them AT the pointed slot (insertion line included) instead of appending: add_track + move_track compose the position locally (QueueManager has no insert-at-index and the manual-block adds would corrupt manual_next_count), and the QConnect controller path routes through CtrlSrvrQueueInsertTracks' insert_after so the position survives to the peer. Multi-row drops keep their own order. Dropping onto an EMPTY queue raises a one-shot 'start playing?' prompt rather than hijacking a running queue or leaving the user one click short. cc |
||
|
|
7754b8bc00 |
feat(qt): Plex PIN sign-in and connection check
The Authorize half of the Plex settings: pin/start + pin/check polling against plex.tv over the shared qbz_plex protocol (the stable per-install client id is reused across both calls or the check never sees the authorization), manual-token mode, and the Check-connection ping that stamps the server's machineIdentifier onto the cache. The panel gates Connect on a pure is-local-address predicate while the user types, matching the reference. cc |
||
|
|
c807d24463 |
feat(qt): local library folder editing
Per-folder settings modal (alias, enabled, network/FS-type override), change-path and a native folder picker feeding the same add path as the typed field, plus the shared double-confirm host used by the destructive rows. Panel refresh no longer blocks on dead network mounts (accessibility probes run off the publish path), and removing a folder prunes its albums from Recently Played so the rail stops pointing at deleted database rows. cc |
||
|
|
13996a0566 |
feat(qt): in-app log viewer
The Developer > Share-logs handoff (open::that on the log file) becomes a filterable in-app view: level filter + substring search run Rust-side over qbz_log's ring and only the survivors cross the bridge (one document, max 500 rows), with auto-tail, copy-all, a GitHub-ready <details> bundle and paste upload. The bundle header now reports the real audio backend via settings_qt::current_backend_label. cc |
||
|
|
5e1eb46a71 |
feat(qt): window chrome parity — macOS tray, native chrome, appearance wiring
Three Slint-parity pieces that share the chrome surface: - macOS menu-bar tray (NSStatusItem, tray_macos.rs) ported from the reference — the K2 'macOS is a new implementation' ruling read the reference backwards (it avoids muda entirely), and its absence made close-to-tray quit the app on the Mac. Plus the custom-chrome window mode (macos_chrome.rs): Qt 6.9 ExpandedClientAreaHint / NoTitleBarBackgroundHint instead of fighting QCocoaWindow's style-mask recompute, traffic-light centring, and the SafeArea inset in HeaderBar. - Appearance rows that rendered, persisted and drove nothing now push live: hide/show title bar, window-controls cluster side, track in the window title, invert swipe navigation, auto-theme source selection (wallpaper/image/system) with regeneration on source change. - use_system_title_bar now defaults per-OS (custom chrome on macOS), matching the reference — the hard-coded 'true' also leaked into the Slint build through the shared ui_prefs.json. cc |
||
|
|
b46924bcc0 |
feat(qt): IconTextButton control and external-link icon
Shared button-with-icon control (used by the queue panel, Plex settings, the log viewer and the library folder editor) plus the external-link icon in every theme tint. cc |
||
|
|
36e8ec990d |
fix(qt): serialize DNS A/AAAA lookups (RES_OPTIONS=single-request)
This host's path to its first resolver drops the second of two parallel DNS queries on one socket, so every getaddrinfo (parallel A+AAAA) stalls for the full 5s RES_TIMEOUT before retrying — the multi-second shuffle-start tax after the app idles, confirmed by the CMAF init timing (send=5449ms, body=0ms) and reproduced app-free with getent + raw UDP probes (parallel loses AAAA; sequential passes in 0.19s). Setting RES_OPTIONS first in main makes glibc serialize the lookups process-wide. No-op on non-glibc resolvers. km |
||
|
|
a561e7ae28 |
fix(player): cancel the superseded track's CMAF segment feeder
The segment feeder spawned by play_track kept downloading to completion after the user switched tracks, holding the link: a shuffle&play pressed mid-stream measured 5441 ms for the new track's init segment (vs 423-760 ms on an idle link) because the init's connect/TLS/TTFB queued behind ~15 MB of in-flight segments of a track nobody was listening to. begin_play (every superseding intent: play_*, stop) now cancels the registered feeder cooperatively via a watch flag the feeder selects on; the in-flight request is dropped immediately. The abandoned buffer is deliberately NOT errored or completed at cancel time, so the outgoing engine keeps playing what is already buffered until the audio thread installs the new source; the buffer is sealed (complete) once the new source is in place (or on stop) so a parked reader wakes to EOF instead of hanging. Cancelled partial tracks are not cached. km |
||
|
|
63b4082e6a |
feat(qobuz): split CMAF fetch timing into send vs body
Diagnoses where slow init-segment fetches lose their time: send(dns+connect+tls+ttfb) vs body. Landed the 2026-08-10 proof that the multi-second shuffle-start stall sat entirely in the connection phase (send=5449ms, body=0ms). cc |
||
|
|
740adf55d1 |
fix(player): clear stale gapless pending on PlayStreaming + lifecycle trace
The PlayStreaming arm was the only start path that did not clear gapless_pending/gapless_request_armed on supersede (Play, PlayDsdDop, PlayDsdNative, Stop and Seek all did). A pending that outlives its engine both blocks gapless re-arming for the new track and fires positionally at the new track's end, swapping current_track_id to a track whose engine no longer exists — observed as the queue stopping dead with a full queue. Also adds [gapless-trace] set/clear logging at every lifecycle point to pin down the surviving X -> X self-transition. cc |
||
|
|
60b8dd13f2 |
test(qt): make the hotkey display test platform-aware
format_display branches on cfg!(target_os = "macos") — glyphs joined by a space there, words joined by " + " everywhere else — but the test pinned only the non-mac spelling, so it could never pass on the Mac. It failed the first time the suite was ever run there. The code was right; the assertion was one-sided. Branch it, and keep the modifier-free cases unbranched since those are what actually pin the glyph table the test is named for. cc |
||
|
|
024ed615e8 |
fix(qt): MainThreadMarker::new() needs objc2-foundation's NSThread feature
Second one the Mac build caught and Linux could not: the type resolved, but `new()` did not exist — it is gated on `NSThread` (objc2-foundation-0.2.2/src/thread.rs:210-213), leaving only the unsafe `new_unchecked`. That is precisely the wrong half: the point of the call is to ASK whether we are on the AppKit main thread and no-op when we are not. cc |
||
|
|
1b18adcde0 |
fix(qt): MainThreadMarker comes from objc2-foundation in this generation
The Mac build caught it on the first try, which is exactly what it was for: `use objc2::MainThreadMarker` does not resolve — in the objc2 0.5 / objc2-foundation 0.2 generation the type is exported by FOUNDATION (objc2-foundation-0.2.2/src/lib.rs:152, ungated); it only moved to the objc2 root later. The reference imports it from the same place (crates/qbz/src/tray/macos.rs:38), which is where this should have been read from in the first place rather than assumed from a newer API. The direct `objc2` dependency goes with it: nothing here names a type from that crate. cc |
||
|
|
3b3ff54964 |
feat(packaging): ship an optional KWin rule to keep the miniplayer above
Measured, not assumed: under XWayland the same Qt hint DOES set _NET_WM_STATE_ABOVE and KWin honours it, so always-on-top is not impossible — it is only impossible for the app to ASK for it on Wayland, where no such request exists in the core protocol. So the app stops claiming it can pin itself and instead ships the thing that works: a window rule matching the miniplayer by its stable, never-translated title, narrowed by wmclass so no other app's window picks it up. The main window is titled "QBZ", which is why the title match is exact rather than a substring. OPTIONAL and never applied by an installer. It is the user's window manager and their configuration; a package that silently rewrote kwinrulesrc would be a worse citizen than one that ships a file and a sentence of documentation. The rationale, the two rejected routes and the import steps are in qbz-nix-docs/qt-frontend/2026-08-04-miniplayer-keep-above-kwin.md. cc |
||
|
|
dc604bdf62 |
feat(qt): wire the macOS Hide-Dock setting — Spotify behaviour on close
The row rendered, persisted and drove nothing, which is the exact defect class the miniplayer/tray contract existed to end. The owner named the behaviour they want: close puts the app in the menu bar AND drops the Dock icon; minimize still goes to the Dock like any app. So only the CLOSE path touches the policy. The wiring point is `QbzTray.setWindowShown`, which QML calls from exactly two places — `hideToTray` and `showFromTray` — so this needs no third visibility owner and cannot drift from the real window state. Hiding drops the icon only when the setting is on; SHOWING always restores it, with no setting check. A user who turns the option off while the app sits in the menu bar must still get their icon back, and restoring an icon that is already there costs nothing. NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind cfg(target_os = "macos"), and a cross-check dies in the C build scripts for want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2 instead — which immediately earned its keep: `NSApplicationActivationPolicy` lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication feature does not pull it. The three features here match the reference's own proven set. First real check is a build on the Mac mini. cc |
||
|
|
8bc32a91a3 |
fix: MPRIS Position is now exact at READ time, not as fresh as the last push
The owner asked whether the remaining ~1 s was the sweet spot. It was not — it was an artefact of fixing this with a push instead of a getter. MPRIS `Position` is read on demand, so what matters is the answer when the client ASKS, not when the app last spoke. The backend stored a snapshot, so the answer was only ever as fresh as the last push, and the only lever left was polling faster — which would have meant a second timer the perf rules refuse. Storing the instant alongside the value removes the tradeoff: the getter returns `position + elapsed` while Playing, exact to the microsecond however rarely the app pushes. The 1 Hz push stops being the clock and becomes what it should have been all along — a re-anchor against drift. Every write re-stamps, including a status change that carries no position: without that the base would keep running across a pause and the clock would count while the music did not. cc |
||
|
|
7ba07ec6f8 |
fix: emit MPRIS Seeked, so an already-open widget stops extrapolating
The owner's observation is what separated the two halves of this: "el contador del seekbar del widget solo empieza a contar hasta que lo abro." A client reads Position ONCE, when it opens, and extrapolates from there — it does not poll. So two different things were broken and only one was fixed by keeping the stored position fresh. Fresh storage fixes what a client sees when it OPENS. `Seeked` is the only thing that reaches one that is ALREADY open: it tells the client to stop extrapolating and re-read. Without it, a seek moved the music and the widget kept counting from where it had been. So the seek path emits Seeked instead of re-asserting the playback state, which was the previous commit's guess at a cure inside an API that had no signal. `mpris_server::Signal::Seeked` was there all along. Defaulted on the trait to `set_position`, so a backend without the signal still gets the value right on the next read and no other consumer changes: cargo check passes for qbz-app and qbzd. cc |
||
|
|
f45b703c64 |
fix: MPRIS reported a frozen position — Position is read on demand
The owner measured QBZ at 4:03 with Plasma's widget stuck at 0:14. The earlier seek fix was right about seeks and wrong about the shape of the problem: the position was not drifting, it was FROZEN. MPRIS `Position` is a read-on-demand property — the spec deliberately keeps it out of `PropertiesChanged`, so a client reads it when it wants it. The Linux backend serves whatever it last stored, and it only ever stored a position when `set_playback` carried one: at a track start (zero) and at a play/pause edge. Between those, every read returned the same stale number for as long as the track ran. This is in the SHARED crate, so the shipping Slint build has it identically — nobody had watched the widget's clock there. `MediaIntegration::set_position` stores and emits nothing, so the Qt poll loop refreshes it on the tick it already computes the position for: one channel send per second, no bus signal, so it does not become the per-tick work the perf rules refuse. The method is DEFAULTED, so no other consumer has to change — `cargo check` passes for qbz-app and qbzd, and the Slint frontend needs no edit (it cannot be built here, per the qbz-ui wall, so it is verified statically as R4 requires). cc |
||
|
|
0372b4d66f |
fix(qt): the mini footer sat 1px outside the card on every side
Measured on the owner's screenshot rather than estimated: the artwork and meta rows span 366 px, the seek and transport rows 368. Qt draws a Rectangle's border INSIDE its bounds, so the card's visible fill is already 1 px in on each side — but the footer was anchored to the card's outer edges and painted straight over the hairline, so the outline stopped exactly where the footer began. Micro looked right because there the footer IS the card and there are no two layers to misalign. Inset by the border width on the three edges it touches, with its corner radius reduced to match so the curve nests inside the card's instead of racing it. No gap opens above it: the surface area still ends at the old boundary, so the two now overlap by a pixel rather than parting. cc |
||
|
|
b7706bdf38 |
fix(qt): drop always-on-top, pin the mini's size, and report seeks to MPRIS
Four things from the owner's smoke, all polish, all measured on real Plasma Wayland rather than inferred. ALWAYS-ON-TOP IS GONE. It shipped as owner ruling K1's default — new work the reference promises in three comments and implements nowhere — and it does not work: a Wayland client cannot raise itself above other clients. There is no such request in the core protocol; it takes a compositor policy or a KWin rule, and Qt has nothing to send. The owner's call was explicit, so the button, the property, the invokable and the pref are all removed rather than left as a control that lies. The capsule is nine buttons again. THE MINI'S SIZE IS NOW FIXED PER SURFACE, min == max. A Plasma tiling shortcut resized the mini to half the screen, the debounced persist wrote 1720 px into the shared ui_prefs.json, and every expanded surface opened giant from then on — with no way back, because this port never gave the mini a resize handle. So the only thing that could ever write those prefs was the window manager, i.e. the bug. The owner asked for exactly this: force the sizes even when the OS says otherwise, stay sensitive to position. min == max gives both — a compositor may still move the window; a resize is refused. That drops the reference's resizable expanded mini and the persisted pair with it (A-6b / AC-15). `saveGeometry` and `geometry_to_persist` stay, with UT-3: they are the right rule for the day a real resize handle exists, and deleting a tested guard to re-derive it later is how this comes back. The micro capsule now centres on the CARD, not on its 17 px header. The reference centres it on the header, but in micro that header is a strip at the top of a 45 px card while the capsule is 26 px tall, so it sat visibly high. A SEEK NOW REPORTS ITS NEW POSITION TO MPRIS. Position was only refreshed on the play/pause edge, so a desktop widget extrapolated from a base the seek had invalidated — the owner measured ~40 s of lag. The shared crate exposes no Seeked emitter and set_playback already carries a position, so the seek re-asserts the state at its target. The shipping Slint build has the same gap; closing it there is a shared-crate conversation, not this one. Not fixed, and it cannot be here: the mini does not remember its POSITION. Wayland gives a client no way to place itself. Contract ruling K4 said so; the owner's session confirms it. It would work on X11. cc |
||
|
|
d003d67b30 |
fix(qt): the mini footer squared the card's rounded corners
The owner photographed it against a white background: every mini surface rounded on top and SQUARE at the bottom, and the micro card — the smallest of them — square on all four. Their read was right: "los NPB son cuadrados y no tienen transparencia, así que aunque el fondo tenga esquinas redondeadas, el npb prevalece". The footer is an opaque Rectangle anchored to the card's bottom edge, and the card cannot round it: QML's clip is RECTANGULAR. theme/RoundedImage.qml records that as measured fact on this Qt build — "the child paints square over the rounded fill" — which is the same reason the artwork backdrop needed a mask two commits ago. So the footer rounds its own corners, with per-corner radii (Qt 6.7+; this tree builds against 6.11): the bottom two in every mode, and all four in micro, where the footer IS the whole card. That last case is exactly why the smallest surface was the one with no rounding at all. cc |
||
|
|
721841a4b8 |
fix(qt): Qt.quit() was vetoed by our own windows, so Quit never quit
The owner reported it three times and their second wording was the whole
diagnosis: "Quit QBZ debe cerrar la aplicación, NO SOLO LA VENTANA."
ROOT CAUSE. `Qt.quit()` in Qt 6 is a REQUEST, not an order. It posts
QEvent::Quit, and QGuiApplication answers it by calling close() on every
top-level window that has a platform handle — hidden ones included. If any
window refuses its close event, the quit is silently cancelled. Two of ours
refuse:
1. Main.qml's closeOrHide refuses whenever trayLive && closeToTray, and
hides the window on the way out. That IS "cierra la ventana, no la app".
2. MiniWindow's onClosing refuses unconditionally, because for the mini a
close means "leave the mini" — so once the mini had ever been opened,
quit was blocked regardless of any setting.
It also explains "Close to tray does nothing": with the setting ON, the X
hid (correct) and Quit hid too (the bug), so the two were indistinguishable
and the toggle looked inert. Nothing was ever wrong with that plumbing.
FIX. Both quit sites call Qt.exit(0), which exits the event loops directly —
no QEvent::Quit, no window to veto it, and no need to touch the miniplayer's
onClosing, whose refusal is correct for what it means. A one-shot 5 s
watchdog thread, armed only at the three quit sites, flushes the log and
_exit(0)s if anything downstream ever wedges; it cannot fire in a live
session. main() now names each teardown step, so a future hang says which
statement it hung on instead of going quiet.
closeOrHide reports its decision through the bridge — tray_live,
close_to_tray and the arm taken — because a report of "the tray does
nothing" was not previously separable from "the signal arrived and QML
ignored it".
MEASURED, with waitpid on a child rather than /proc (a zombie reads as alive
and that produced a false diagnosis earlier in this session). Old binary:
alive ≥25 s with the window visible, hidden, and with the mini open. Fixed:
exit 0 in 0.35-0.68 s across eleven driven scenarios — the tray Quit in all
three window states, the drawn X and the app-menu Close with the setting
both ON and OFF, the mini's own close button, and the MPRIS Quit method.
One arm remains static-only: a real WM close event with close-to-tray OFF.
VNC delivers no such event, so the owner's first titlebar X is its first
real execution.
cc
|
||
|
|
ce7f29ff9c |
chore(qt): ship the binary as qbz, and log the first MPRIS metadata push
The artifact is now `target/<profile>/qbz` (owner, 2026-08-04). The package stays `qbz-qt`, so `cargo build -p qbz-qt` is unchanged. ⚠ `crates/qbz` — the Slint binary — declares the SAME bin name and shares this workspace's target dir, so both write `target/<profile>/qbz` and the last build wins. Survivable only because the Slint binary is never built in this worktree (the qbz-ui 20-30 GB wall); if it ever is, a smoke against "the wrong qbz" is silent. Written into Cargo.toml beside the name. The MPRIS line exists because "Plasma sees nothing" has two very different causes — the player never registered, or it registered and no metadata was ever pushed — and the log could not tell them apart. It settled that question the same day: the owner's log shows `[mpris] metadata pushed: Holy Diver — Dio (350s)`, so the port's half works and what remains is desktop-side. cc |
||
|
|
684fffd7f8 |
feat(qt): the mini queue and lyrics surfaces
Block B4, the last code block of the miniplayer/tray contract. MiniQueueSurface — the current track first, then the FULL upcoming list: uncapped, unpaginated, a position number where the main list has artwork, and hover beating active on the row background. A recycling ListView, since a 2,000-track queue in a 380 px card is exactly the case the track's perf rule forbids materialising. Its document is applied IMPERATIVELY, and that is the perf rule, not style. A bound model hands the view a new array on every publish that differs, which is a full model reset: a user scrolled to row 120 gets yanked to the top every time the track advances. The upstream guard only suppresses IDENTICAL republishes — a real change is precisely what it lets through. The cure is the port's own, from Cortinilla: latch contentY, apply, restore under Qt.callLater so a burst costs one restore. MiniLyricsSurface — the empty test is on the line COUNT, not on status; there is no loading, error or offline state in the mini. It mounts its own LyricsSyncEngine, which is what makes the reference's ensure_lyrics_engine hack unnecessary: the karaoke advances with the sidebar panel closed. Both surfaces are gated on QbzMini.open, not on the surface alone. The mini window is created once and never destroyed, so a surface-only gate would leave them mounted and re-parsing a whole JSON document on every queue or lyrics edit for the rest of the session with the mini nowhere on screen. The lyrics pump additionally reads the MINI WINDOW's visibility. Section 4.5 spells the gate as open + surface + synced, but QbzMini.open is a logical flag that stays true when the window is minimised, and the general rule says that gate must read the mini's own window. The window term is added, not substituted. queue_play routes remote-first and RETURNS on Err — an error must never fall through to local playback — and its row-to-index mapping calls the tested helper rather than restating it. Row 0 resumes only when a current track exists; with none it plays upcoming[0], which is the reference's off-by-one this port declines to inherit. mini_qt::clamp_to_implemented is DELETED. It was the temporary guard that kept an unimplemented surface from stranding the user on a blank card, and its doc said the last block to land would remove it. Driven and seen: ten rows with their numbers, titles, artists, durations, the E badges on the two explicit tracks, and no artwork. What is NOT seen, because it needs sound: a row click actually playing, the off-by-one on live behaviour, and the karaoke advancing. cc |
||
|
|
6b17db90dd |
feat(qt): the mini footer, the hover capsule and always-on-top
Block B3. The owner's verdict on the four blocks that landed before it was that the miniplayer was "no draggeable, no hay controles de ningun tipo. Ni de reproducción, ni para cambiar entre modos, no es pinneable." All of that lived here. qml/miniplayer/ — MiniFooter with its three modes, and the five primitives the reference declares as inner components: MiniSeek, MiniTransport, MiniVolume, TBtn and CapBtn, one file each because a 396-line Slint footer folded into one QML file lands well past the size rule. MiniWindowControls is the hover capsule: five surface tabs, a separator, then expand, pin, backdrop, drag and close, plus the collapsed square trigger. The transport in full and compact is centred by a PHANTOM SPACER the width of the volume button, not by a centred layout — micro has no volume and is the one mode genuinely centred. The seek carries the reference's pending machine: the -1.0 sentinel, the 0.02 convergence window and the 8s watchdog. The capsule is a conditional MOUNT, so leaving the card destroys its expanded flag and its 250ms timer with it. ALWAYS-ON-TOP is new work, not a port: three comments in the Slint tree promise it and no code implements it. The owner asked for it back because Tauri had it and Slint structurally cannot. Qt.WindowStaysOnTopHint, a pin button, and a persisted pref that defaults ON. COLOUR LITERALS: six were carried over from the reference verbatim and every one of them was WRONG. Slint writes #RRGGBBAA; Qt parses #AARRGGBB, so #ffffff33 is not white at 20% but opaque pale yellow — which is exactly how the capsule rendered until it was driven and looked at. This is the second time this trap has landed in this port. Each converted literal now carries the reference form beside it. One of the six is MiniShell's, from B2. mini_qt.rs — surface 0 leaves the temporary clamp, because the micro footer IS the card and now exists. Surfaces 3 and 4 stay clamped until B4. Driven and seen, not inferred: the footer in artwork and micro, the capsule revealing on hover and collapsing on leave, the micro card at 380x57 with its centred transport, and the pin persisting mini_always_on_top across a toggle. The drag handle is wired to startSystemMove on pressed; the VNC platform cannot move a window, so that one is owed a real compositor. cc |
||
|
|
5dd450fb9f |
feat(qt): MPRIS — the desktop can finally see the player
Owner ruling K3 kept MPRIS out of the miniplayer/tray contract, and its default shipped. The owner reversed it after smoking the tray: QBZ did not appear in KDE Plasma's now-playing widget. Plasma reads MPRIS, so without this the desktop cannot see the player at all. src/media_controls_qt.rs (new) — a plain module over the SHARED qbz-media-controls crate, following the qbzd precedent rather than the Slint one because qbz-qt is also not a Slint app. Outbound: metadata on the track edge, playback state on the play/pause edge, both carrying position. Inbound: play, pause, toggle, next, previous, stop, seek and volume, plus Raise — which calls tray_qt::present() unchanged, the mini-aware verb the tray contract shaped for exactly this caller. src/artwork_qt.rs — cached_raw_path, the remote arm of cached_path without its file_url step, so the art URI is built for MPRIS rather than for QML. src/playback_qt.rs — four sibling pushes beside the tray's: both arms of refresh_now_playing, and BOTH is_playing != was_playing blocks. One of two is the silent half-port, so the grep is the instruction. Verified on the session bus, not by reading: qbz-qt owns org.mpris.MediaPlayer2.com.blitzfc.qbz, Identity "QBZ", DesktopEntry com.blitzfc.qbz — that is what gives Plasma the icon — with CanControl, CanPlay, CanPause, CanGoNext, CanGoPrevious, CanSeek, CanRaise and CanQuit all true and PlaybackStatus tracking. Metadata is empty until something plays, and that half is NOT verified: it needs audio. cc |
||
|
|
a64c20b09f |
fix(qt): never let the shutdown path outlive the UI, and log the tray hop
An owner smoke reported no way to close the app but killing the process. That was not reproduced here — driven through D-Bus exactly as a panel does it, the tray's Quit exits in 0.4s and the app-menu Close with close-to-tray OFF exits in 0.5s, both code 0 — so this is insurance and instrumentation, not a claimed cure. The insurance: once the event loop is gone the process has no UI, so nothing on that path may block without a bound. The cast shutdown ran unbounded and takes a tokio Mutex the cast poll task also holds, aborted only AFTER the lock is acquired. Two seconds is the cap; a cast device that keeps playing that much longer beats an app that cannot be closed. The instrumentation: tray_qt logs when it SENDS, and nothing logged on the QML side, so "the tray does nothing" could not be told apart from "the signal arrived and QML ignored it". The three handlers now say which. That ambiguity cost a diagnosis today; one line each ends it. cc |
||
|
|
088387f2c1 |
feat(qt): close-to-tray across every exit, and the macOS tray settings rows
Block B6 of the miniplayer/tray contract. B5 landed a tray whose clicks reached Rust and stopped there; this is the other half. qml/Main.qml — hideToTray(), showFromTray() and the ONE close choreography every exit now calls, plus the handlers for the tray bridge's four signals and the mini's close-app signal. The close predicate is both conditions, trayLive && closeToTray: with the tray off a close QUITS, because gating on the setting alone strands the process with no window on a desktop that has no SNI host. close.accepted = false happens before the hide — hiding never arms Qt's quit check, an accepted close does. The miniplayer's enter and exit are rewired onto those two verbs, which is what makes the tray toggle honest: the reference hides the main window behind the tray's back and its second tray click brings the main window up beside a live mini. THE EXITS ARE FIVE, NOT FOUR. The contract enumerated four and gave the grep that finds them; re-run today it returns a fifth, because the kiosk shell grew its own drawn close button after the contract was written. It goes through the same choreography. Four POC-NOTEs die here. showFromTray also un-iconifies. Qt's hide() preserves windowStates where the reference destroys the surface, so a window minimized before it was hidden would otherwise come back minimized — present in the taskbar, invisible on screen, which reads as the tray having done nothing. qml/settings/AppearanceSettings.qml + src/settings_qt.rs — the three macOS rows: the MENU BAR header swap, the macOS wording on Close-to-tray, and the Hide-Dock row, all absent on Linux. minimize_to_tray gets storage and no row, which is the owner's standing decision. And the tray-close-to-tray write arm now pushes to the bridge beside its persist, so toggling it changes the very next close with no restart. src/mini_bridge.rs — close_app and the signal it routes through, so the mini's red X will honour close-to-tray like every other exit. Its button is B3's, so the chain has no caller yet. Driven, not assumed: with the tray live and close-to-tray just pushed false, the app-menu Close row QUIT the process — which can only happen if the push reached the bridge, since the seed had set it true. And Shift+M then Escape returns the main window pixel-identical, zero of 1.6 M pixels changed, with the mini alone on a black framebuffer in between: the main window really is hidden, and Qt's hide/show preserves its geometry with no restore call. cc |
||
|
|
cd89b40945 |
feat(qt): the miniplayer window, shell and the two display surfaces
Block B2 of the miniplayer/tray contract, and the block that settles the premise the whole port rests on: a second top-level Window in the same QQmlApplicationEngine reads the SAME bridge singletons as the shell. Proven by driving it — a probe window with one Text bound to QbzPlayer.npTitle tracked the main window across a track change with no mirror code. Slint needs ~155 lines of mirroring for this; Qt needs none. qml/miniplayer/ (new, six files) — MiniWindow (frameless, transparent, transientParent null, its own key host and its own onClosing), MiniShell (the card, the artwork backdrop, the surface router), MiniCoverArt and MiniExplicitBadge (the two components MiniBits.slint exports, which cannot share one .qml file), and the compact and artwork surfaces. The card is the window minus a 6 px gutter, its radius is 9 for the condensed surfaces and 10 otherwise, and switching the surface RESIZES THE WINDOW — 540 to 178 and back, driven and measured, not inferred. The backdrop lives inside a masked container. `clip: true` on a rounded Rectangle does NOT round its children on this Qt build — theme/RoundedImage.qml records that as measured fact — so without the mask the scrim and the atmosphere's own opaque root would paint square corners over the card the moment the backdrop is on. That is reachable before its toggle exists, because the pref is co-owned with the shipping Slint app. src/mini_bridge.rs — enter/exit, the mini's own key dispatcher, and toggle(). Re-entering an open mini re-raises it rather than doing nothing, which is what the reference does and what present() will want. src/hotkeys_qt.rs — action_for_mini_key, a SIBLING of action_for_key with the context arm inverted. The main window still refuses Context::Mini and the test that pins it is untouched; a new test pins the mirror. src/hotkeys_bridge.rs — the ui.miniPlayer arm stops logging and toggles. qml/shell/ViewModeMenu.qml — the Miniplayer row goes live with its own dispatch arm, and it is hidden under the kiosk profile, 1:1 with PlayerBarSmall.slint:819. The kiosk port landed first, so that gate was owed here. Three comments claiming the row is inert are corrected. Surfaces 0, 3 and 4 are clamped to the artwork surface until their content exists (B3 and B4), because mini_surface and mini_default_view live in the prefs file shared with the Slint app: a user whose Slint mini was last on Queue would otherwise open onto a blank card with no visible way out. Each block deletes its own entry from that clamp. Enter does not hide the main window yet — window.hideToTray() is B6's, and open-coding a bare hide() here is what the one-visibility-owner rule forbids. So the main window stays visible behind the mini in this commit, by design. cc |
||
|
|
56d4387172 |
feat(qt): system tray via ksni
Block B5 of the miniplayer/tray contract. The tray settings rows have rendered and persisted since the Settings port and driven nothing; this is what they drive. src/tray_linux.rs (new) — the ksni StatusNotifierItem: the twelve icon pixmaps, the variant ladder, the tooltip, the six-item menu, left/middle click and scroll, and the qbz-tray-updater thread every mutation crosses. Note the filename inversion it reproduces: tray-light-* holds the BLACK glyph, so the "mono-light" setting loads tray-dark-*.png. src/tray_qt.rs (new) — the portable half: init's three gates, the window verbs, the 400 ms toggle debounce, the transport dispatchers and in_gamescope(). toggle_window is mini-aware — while the mini is open a tray click raises the MINI, because a mini-blind toggle would put the main window beside a live mini on the first click. src/tray_bridge.rs (new) — QbzTray: trayLive, closeToTray, and the four signals the ksni thread reaches QML through. Rust cannot touch a QQuickWindow, so every verb is a signal. icons/ — the twelve PNGs, copied rather than include_bytes!'d across crates, so this crate does not depend on a file inside the Slint binary crate. src/playback_qt.rs — the three live-update pushes: set_track and clear_track in both arms of refresh_now_playing (the publisher with 23 call sites, so a QConnect or cast track change updates the tooltip too), and set_playing in BOTH is_playing != was_playing blocks, each beside the existing discord_push. src/settings_qt.rs — tray() widened to pub(crate) for the session-entry read; the tray_close_to_tray snapshot default corrected from false to true, which was cosmetic while nothing consumed it and becomes behavioural now; and the tray-icon-theme select arm re-themes the running icon with no restart. Cargo.toml + Cargo.lock — ksni 0.3 with default-features off. Never the tokio feature: the blocking API panics inside tokio and the panic is swallowed, which is why the two dedicated threads exist. src/main.rs, build.rs, qml/Main.qml — the registrations, and tray_qt::init in on_session_entered. That call site is also deliberately the process's first settings_qt::tray(), because the store binds per user and a read from the bridge's construction seed would latch an empty one at the login screen. The Connections block that answers the four signals lands with B6, together with the window verbs it calls. Until then the tray renders, its tooltip and menu are live, and its clicks reach Rust and stop there. cc |
||
|
|
0d6e7d3498 |
feat(qt): QbzMini bridge, mini queue projection and mini prefs
Block B1 of the miniplayer/tray contract. No UI: this lands the state the five surfaces will bind to, so B2 can mount a window against a bridge that is already correct. src/mini_bridge.rs (new) — the QbzMini singleton. Six properties (open, surface, backgroundBlur, miniQueueJson, miniWidth, miniHeight), boot(), and the two custom-WRITE funnels. `open` mirrors into a static AtomicBool with is_open() beside it, because the hotkeys pipeline runs on another singleton and cannot read this QObject's properties. src/mini_qt.rs (new) — the plain projection module: mini_queue_doc (current track first, then the FULL unfiltered upcoming, no page cap, no art field), the row-to-upcoming-index mapping, the geometry guards, and the pref seeds. Five unit tests, three of them the contract's UT-1/UT-2/UT-3. src/queue_qt.rs — display_title and fmt_duration widened to pub(crate) so mini_qt reuses them instead of growing a third private copy; new publish_mini_queue with its own identical-document guard, published from THE queue funnel beside publish_coverflow. No second queue fetch. src/settings_qt.rs — pref_f32, the reader with a default that the two geometry keys need. Also reattaches three doc lines that sat on read_pref_f32 while describing save_pref, which had none. src/main.rs, build.rs, qml/Main.qml — the registrations: two mod lines (mini_qt is a plain module and is deliberately absent from rust_files), the bridge in rust_files, and QbzMini.boot() after QbzSession.boot(). Two deviations worth naming. Contract row A-9 asks for an invokable set_surface, but row A-3 fixes a property named surface whose generated setter is also set_surface — one QObject cannot carry both, so the funnel sits on the property. QML assigns `QbzMini.surface = n`; there is no setSurface member. And the surface write persists and re-kicks the queue refresh unconditionally, 1:1 with the reference, suppressing only the change notification when nothing moved. cc |
||
|
|
00eaa00027 |
docs(qt): the ambient POC-NOTE in AppShell was a fossil that lied
AmbientField is mounted at :46 and ambientOn derives from QbzShell.ambientMode, so the note claiming the artwork-derived background is unimplemented had been false for some time. Corrected in passing because this port's diff touches the file -- LESSONS-2026-08-03 L10 asks for exactly that rather than propagating it. cc |
||
|
|
28d66352cb |
fix(qt): four defects the independent deferral hunt found
An agent that did not write this code audited it against the contract's §9, §7 and §11.3. Four of its five findings are fixed here (the fifth, the sync() ordering, landed in the previous commit). 1. QBZ_KIOSK_FULLSCREEN was INERT and the log LIED about it. fullscreen_at_boot() had exactly one caller -- choosing a log string -- so an appliance boot with the env set stayed windowed while printing "-> fullscreen-at-boot". The reference does the real thing at qbz/src/main.rs:8611. Now a seeded-at- construction QbzShell.kioskFullscreenBoot drives Main.qml's `visibility` declaratively, so it is part of the FIRST mapped frame. It is one-shot by construction and requires BOTH the profile and the env, which is what keeps a desktop user who toggles kiosk and restarts from being trapped in a shell with no titlebar control (incident 2026-07-11). 2. Shift+I was a reachable DEAD STATE. The rebindable ui.focusMode action reaches the hotkeys dispatcher from the kiosk root like any other non-nav key, so QbzImmersive.open went true over the sanctioned EMPTY mount -- and the kiosk root then rejects every arrow, Enter and Esc while it is true, so the whole zone model froze against a surface that renders nothing. The visualizer tile already declined to open it for this exact reason; the guard now sits in apply_open, the funnel every entry passes through, because the tile was not the only door. It refuses to OPEN, never to close. 3. Kiosk chrome on the small bar was half-ported. The reference HIDES the Lyrics and Queue buttons in kiosk (PlayerBarSmall.slint:846, :983); the port gated only their click handlers, so both still rendered and still toggled side panels the kiosk shell never mounts -- inert affordances, which §9.2 forbids. 4. "Kiosk mode" and "Desktop mode" shipped untranslated. My check_msgids.py compared against the 31 @tr strings in the Slint KIOSK sources, which do not include the two strings this port adds to the view-mode menu -- the checker was right and its input was incomplete. Both now in all 7 translated locales. Verified by driving, in one frame: the small bar's right cluster no longer carries the Lyrics and Queue glyphs, and after Shift+I two Rights still move the shelf cursor (scratchpad/kiosk/f-02-after-shiftI.png). The fullscreen arm is verified only as far as a headless run can go -- both env branches log correctly and the binding resolves -- since actual fullscreen needs a real display, which is Pi/owner-smoke territory. cc |
||
|
|
bf74e77f6b |
fix(qt): the shelf pulse could latch and go dead; re-arm it
Two defects in one seam, both found by an adversarial reviewer reading the diff of the previous commit -- neither has a test that could have caught it, and both are silent. 1. That commit moved set_nav_active BELOW set_hmove. The first arrow key of a session sets both in one model call, so hmoveChanged fired while nav_active was still false and no shelf consumed the pulse. nav_active is CONTEXT a handler reads, not something consumers have a handler on; hmove is the third consumer-facing field, alongside index and activate_seq. The ordering comment undercounted them. Fixed. 2. The deeper one, which predates that commit: hmove is cleared only by a consumer calling takeHmove(). If nobody consumed the last pulse -- no shelf focused, the ring not live, a skipped shelf -- the model still holds it, so the next Right writes 1 over 1 and cxx-qt's generated setter is CHANGE-GATED: it returns without emitting. The key is then dead until the user happens to press the other direction. hmove's value is not its meaning; the edge is. nav_left/nav_right now force the property to 0 first, so there is a real 0 -> +/-1 transition every time regardless of what the consumers did. Verified by effect, not by reading the diff: four consecutive Rights on a Discover shelf move the cursor four cards (scratchpad/kiosk/s-02-four-rights.png). cc |
||
|
|
e5f93d6805 |
fix(qt): the stale-derived-read hazard, swept across the kiosk views
KioskArtist's TypeError was found by luck, not by a method, so an independent agent swept every on<X>Changed handler in the kiosk tree for the same class: a handler reading a property that is DERIVED from the very thing it fired for. Three more, all silent: - KioskDiscover.publishNav() read `navSectionCount` from inside onActiveTabChanged, so it published the PREVIOUS tab's section count (and NaN on the creation pass). publish() clamps `index` into the count it is handed, so a wrong count is a focus ring parked on nothing, not a cosmetic slip. - KioskMyQBZ.publishNav() had the same shape against `cards` and onOnCollectionsChanged, counting the previous route's document. - KioskArtist's shelf skip-cascade read QbzKioskNav.dir inside onIndexChanged. That last one was MY defect, not the view's: sync() wrote `index` before `dir`, and each cxx-qt setter emits its NOTIFY synchronously, so the handler ran while `dir` still held the previous move. The first Down of a session read dir == 0 and refused to skip, parking the ring on a shelf of zero height; a direction reversal skipped back the way it came. Fixed at the source: sync() now writes every CONTEXT field (zone, columns, count, tabs, shelves, dir, hmove, nav_active) before the two fields consumers actually react to (index, activate_seq). The QML keeps its Qt.callLater as well -- that covers Qt's own binding re-evaluation order, which is a different hazard from the setter order. Two independent guards, because this class is invisible to every static check. The general remedy, in all four sites: compute from the source value at call time, never read a cached derived property inside its own handler. Written up at qml/Main.qml:233-247 from the window-geometry work. Gate: 9 warnings = baseline, 291 tests, both audits OK, qmllint clean on every touched file, kiosk-profile smoke clean, and the shelf skip cascade re-driven over VNC (scratchpad/kiosk/s-01-skip-cascade.png). cc |
||
|
|
ea26d743ab |
fix(qt): KioskArtist threw on every mount and skipped the first cover dispatch
Found by DRIVING the app, not by the gate: opening an artist logged "TypeError: Cannot read property 'length' of undefined" at KioskArtist.qml:106 on every mount. The view still rendered, which is why nothing else caught it. collectArtwork() read root.sections -- a readonly property derived from `artist` -- from inside onArtistChanged, which is connected to the very notify that dirties it. The handler runs BEFORE the dependent binding re-evaluates, so the read saw undefined. Main.qml:233-247 already documents this hazard and its remedy from the window-geometry work: compute from the value at call time, never read a cached derived property inside its own handler. Cost of the bug: the first artwork dispatch for an artist page was skipped entirely, so covers only arrived if a later pass happened to re-request them. Verified BY EFFECT -- rebuilt, re-drove to the artist page over VNC, and the run log is now clean -- not by re-reading the diff. cc |
||
|
|
63e36c7543 |
feat(qt): the kiosk shell, its NavRail, the 4 card primitives and the 8 views
Contract sections 4, 5, 6 and the QML half of 7.
ContentRouter.qml is the D3 extraction: ONE view-mount chain, shared by both
shells. The Slint kiosk's content block is a verbatim copy of AppShell's and
KioskShell.slint:10-17 names this extraction as the follow-up it was waiting
for. 23 desktop arms carried over with their comments, 8 kiosk overrides, and
the kiosk-only nowplaying route. AppShell's four viewLoader.item sites now go
through contentRouter.currentItem; a tree-wide grep confirmed no other
reference (LESSONS L4 -- grep, do not trust memory about "both places").
KioskShell.qml carries the back bar with the full search focus lifecycle, the
content-height arithmetic including the now-playing exception, the offline
gate, the forced small transport, the player-zone focus ring, the root key
handler and the 30ms initial focus grab. Two findings worth keeping:
- the NavRail tiles fire header-menu-navigate("favorites-tracks") in the
reference, which switches the view AND selects a landing tab; navigateTo
carries only the route, so the tiles reuse the port's existing pending-tab
handshake rather than landing on the wrong tab.
- the visualizer tile's action is a sanctioned no-op (R2). Wiring
QbzImmersive would be worse than nothing: `open` would go true over an
empty mount and the key guard would then reject every key against a
surface that renders nothing.
Three fractional font sizes had to be rounded. Qt's font.pixelSize is an INT
property, so the reference's 11.5px (NavRail.slint:47) and 12.5px
(KioskNowPlaying.slint:59,67) are not expressible; font.pointSize is the only
real-valued alternative and is DPI-relative, which would make those three
labels scale differently from every other in the tree.
The NavRail one was caught by the offscreen smoke run IN THE KIOSK PROFILE --
it made the whole rail unavailable, i.e. a kiosk with no footer. The build,
both QML audits and qmllint were all green while that was true, and the
desktop smoke was clean because it never mounts KioskShell. The other two
were still latent: KioskNowPlaying is not on the smoke's route.
Gate: build 9 warnings = baseline, 291 tests, both audits OK over 229 QML
files (+15), qmllint filtered = the same 20 pre-existing ArtistView lines and
nothing else, kiosk-profile offscreen smoke clean.
cc
|
||
|
|
221f8704d5 |
feat(qt): kiosk profile — resolution, boot and the live toggle
Port of contract section 8. Three Slint blocks, faithfully:
- main.rs:7855-7876 kiosk_profile_active() / resolved_shell_screen()
- main.rs:8586-8618 kiosk entry (forced reduce-motion; fullscreen ONLY via
QBZ_KIOSK_FULLSCREEN, windowed otherwise -- incident 2026-07-11: a user who
toggles kiosk on the desktop and restarts would be trapped, since the kiosk
shell has no titlebar control and neither Esc nor F11 leave fullscreen)
- main.rs:11767-11780 the live toggle, in the reference's order: persist,
flip the flag, then swap the screen. The flag first means the shell that
mounts next reads a correct kioskProfile on its FIRST frame.
"kiosk" is a SIBLING screen of "shell", not a mode inside it, which is how
app.slint:38 models it (AppScreen { splash, login, shell, kiosk }, sibling
mounts at :145 and :205). Both shells read the same bridges, so the toggle
tears nothing down and playback keeps running.
The toggle reads the CURRENT profile rather than re-resolving: resolving
would let a QBZ_PROFILE env override pin the reading and a user on a kiosk
image could never toggle out (the reference says so at :11764-11765).
ViewModeMenu's delegate called QbzImmersive.openFromMenu() unconditionally,
so flipping any row's `live` would have made it open Immersive. Each row now
carries its own action. Miniplayer stays the one inert row.
The small bar's view button becomes the toggle itself in kiosk
(PlayerBarSmall.slint:746-750): every other flyout row is gated
!kiosk-profile there, so the menu would carry a single row.
reduce_motion's kiosk half is now live -- shell_bridge.rs had the seam
pre-built with a `false` literal and a note saying one line, on their side.
Also: the gear icon. NavRail tile 7 uses settings.svg; this port only had
settings-2.svg, which is two sliders, a different glyph.
288 tests (4 new), 9 warnings = baseline, both QML audits OK.
cc
|
||
|
|
87c20fce8f |
feat(qt): kiosk zone-navigation model (QbzKioskNav)
The Qt counterpart of Slint's KioskNavState global (state.slint:3511-3536) and the five movement functions on the kiosk shell (KioskShell.slint:95-200). The state machine lives in kiosk_nav_qt.rs as a plain Rust model so cargo test can reach it: the contract names the zone model as the piece a lazy port silently degrades, and it is invisible to every static check the gate runs -- no types to resolve, no bridge members to cross-reference, and a click never sets nav_active, so the whole model can be dead while every screenshot looks right. 22 tests cover the transition table. A QML pragma Singleton was not available: the cxx-qt-build generated qmldir cannot mark singletons (theme/QbzTheme.qml:12-13), and an object rooted in the shell cannot be reached by views mounted inside a Loader without a parent-chain walk. Two departures from the Slint original, both deliberate: - columns is clamped to >= 1. Slint's Math.mod(x, 0) is NaN and the comparison quietly fails; Rust's % panics, so a view publishing 0 would take the app down. No behavioural change for any legal value. - Up from row 0 of a view with no tab strip goes to the rail. Slint runs min(tabs - 1, index - tabs), which for tabs == 0 is min(-1, 0) = -1; no item carries index -1, so the ring vanishes until a second Up. Live on KioskAlbum (:46) and KioskArtist (:24), both of which publish tabs = 0. No boot() and no CxxQtThread: nothing in Rust ever publishes into this object, so a hop would be a dead function that looks load-bearing. cc |
||
|
|
085fed81fb |
fix(qt): has_more on local sections counts matching groups only
Self-review catch on the commit before this one. In the album derivation the match filter ran AFTER the total was bumped, so every group the filter rejected still inflated the count and has_more came back true over a destination with nothing extra to show - a "View more" link that lies. The artist derivation already had the two in the right order; the two were inconsistent with each other, which is how it slipped past. Test covers the shape directly: one matching album among six rejected groups, under a cap of 3, must report has_more false. cc |
||
|
|
422e5fd729 |
fix(qt): local sections match the query, and the ranking learns from the page
Three ranking/search defects, two of them found by the owner driving the app.
1. LOCAL sections showed groups that do not match the query.
Searching "Iro" listed Cynic and Die Toten Hosen under "Artists on Local
Library", and their records under "Albums on Local Library". Neither name nor
album title contains "iro". The cause: the SQL matches at TRACK level -
title OR artist OR album - and the album/artist sections are DERIVED by
grouping the matched tracks, so one track-title hit drags its whole album and
its artist into sections that are supposed to be about albums and artists.
The Qobuz sections beside them do not behave that way, so the local block was
lying about what it was showing. Derived rows are now kept only when the
group itself matches: album title or album artist for albums, name for
artists. The TRACKS section is untouched - a track matching on its title is
exactly what that section is for.
The reference has the same flaw; this is a deliberate divergence from it, on
the owner's report. Recorded as such so no future audit "restores parity" by
putting the noise back.
2. The results page taught the ranking nothing.
The port had exactly ONE record site, the cortinilla row click. The reference
has two - the second fires on any media action while the Search view is
current. Without it the case the whole feature exists for is broken:
search "one metallica" -> right track first -> click. Learned.
search "one" -> Qobuz buries it, so it is not in the dropdown ->
"View more" -> click it on the page -> NOTHING is
learned, and the next "one" is just as bad.
record_page_interaction is gated exactly like the reference: Search view
current, module enabled, non-empty query, and the query is the PAGE's, which
is committed by definition. Hooked into the four crate-level media routers,
each self-gated so every other caller is unaffected.
3. Prefix attribution (contract S-N4, owner ruling R8 option ii).
A cortinilla click was learned against the PAYLOAD's query, which lags what
the user has typed: they keep typing while the previous payload is shown,
then click. That teaches a bucket named after a half-typed word - "annen"
learning from a click made while typing "annenmaykantereit". On the owner's
real store, 25 of 143 buckets are typing prefixes like that, and none will
ever be typed again as a whole query, so the score is simply lost.
The promotion is deliberately narrow: only when the payload's query is a
STRICT PREFIX of the live one. Anything else - box cleared, unrelated text,
a narrowing rather than an extension - is attributed as before, because a
broader rule would start moving interactions between unrelated buckets.
DECAY IS NOT IMPLEMENTED and that is the ruling, not an omission: the max
observed score on a real machine is 6 against a cap of 1000, so nothing is
saturating, and decay would add a tunable constant calibrated on nothing
while visibly reordering results.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit S-N4
plus two owner-reported defects.
Gate: build clean, 261/261 tests (3 new pinning the owner's exact "Iro" case).
cc
|
||
|
|
28c04bd990 |
docs(qt): correct the stale search notes
The two whose falsifying commit does not touch their file, so R7 rule 2 could not put them beside the code. QbzEmptyState claimed it had consolidated SearchView's and Cortinilla's no-results states. Neither uses it - both draw their own line inline, and the cortinilla's is a single 28px row inside a dropdown rather than a centred block. SearchView's SOURCE note justified its Qobuz-only assumption by saying the local sections "are not ported". They are now. The assumption still holds for the results PAGE, which is Qobuz-only, so the note is narrowed rather than deleted - the cortinilla is a different document whose rows carry their own source field. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C12 (R7-7, R7-8). cc |
||
|
|
b38b0b3d57 |
feat(qt): instant cached cortinilla paint (default on, ui_prefs opt-out)
The reference shows a skeleton while loading and never paints cached rows
first, and its comments present that as the design. It is not: the instant
paint existed during development and was removed because it GLITCHED -
results visibly jumped between the cached paint and the fresh one. The
owner's reading is that the glitch was a Slint rendering problem rather than
something inherent to stale-while-revalidate, so it comes back here as a
reversible experiment: DEFAULT ON, with a ui_prefs opt-out
(cortinilla_instant_paint) that needs no rebuild to flip. The skeleton path
stays fully implemented as both the opt-out target and the cold-miss path.
What is cached is the FINISHED payload, not the API response. Reviving the
existing CAPA A result cache would have left the expensive half on the table:
a cortinilla payload is the response AFTER ranking, truncation, i18n titles,
the top-result pick and - the part no API cache can shortcut - the local
library query and its derived grouping.
qbz-app gains a generic PayloadCache<T>. The store never inspects T, which is
what lets both frontends share it despite their payload types having diverged
(one raw artwork_url vs an art_url/art_path pair). In-memory only: process
lifetime IS the TTL, because a payload embeds local rows and resolved artwork
paths and a persisted entry would resurrect albums the user has deleted.
Four properties are what should keep it from glitching, and each is a real
constraint rather than a hope:
- Same shape in both paints. The entry is written AFTER the local sections
are appended, so there is no mid-apply reflow left to see.
- Equality-gated replacement. CortinillaData derives PartialEq, so an
unchanged repeat query produces ZERO second paint.
- The selection survives - the publish path never writes selectedIndex.
- The scroll survives - C5's contentY latch. Without it the second paint
yanks the user to the top, which is the glitch by another name.
There are exactly TWO write points, and the placement is load-bearing: after
pass-1 artwork resolution, and again at the pass-2 republish once downloaded
covers land. Writing earlier caches rows with empty art_path, and since the
QML draws art_path only, a hit would paint cover-less rows AND the gate would
fire on every hit - the exact repaint this exists to avoid.
Invalidation: bound per session, dropped on logout, and cleared from
reload_browse - the single chokepoint every local-library mutation passes
through, since there is no library-changed signal to subscribe to.
Also fixes the immersive skeleton's scope bug, which is the twin of the one
C4b fixed on the desktop and which I missed there: a Timer derives from
QtObject, which has no parent property, so the unqualified parent resolved
out of the block entirely - the timer ran forever, pulse never changed, and
the skeleton never pulsed. Found by qmllint, not by reading. It picks up the
reduceMotion tick with the fix.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C11
(section 5.c, rulings R1/R6, R7-10).
Gate: build clean, 258/258 tests + 2 new in qbz-app, both QML audits OK,
qmllint clean on both cortinillas, offscreen smoke 0 complaints + home
published.
cc
|
||
|
|
10f249be24 |
feat(qt): module-OFF results fallback and search service teardown
Two gaps that both made the port quietly lose a capability. Turning Intelligent Search off is a SWAP in the reference, not a removal: typing live-navigates to the full results page on a 300ms debounce instead of opening a dropdown. Here it returned, so with the module off typing did nothing at all until the user pressed Enter - the kill switch removed the feature rather than exchanging it. The per-user search service had no teardown on logout, so the next account inherited the previous one's learned ranking: top_for_query would promote a stranger's most-clicked result and the interaction store would keep accumulating under their buckets. The reference tears it down for exactly this reason. The version counter and both payload snapshots are cleared with it, so a load still in flight at logout cannot publish into the next session. auth_qt's asymmetry note is updated rather than left claiming a gap that is now closed. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C10 (J5, J7, R7-5). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
06e8b4ba4d |
fix(qt): pass the cortinilla row's block padding in instead of reading parent
The row width read parent.leftPadding / parent.rightPadding. It works at runtime - the parent is always a Column that has them - but `parent` is typed QQuickItem, which does not, so qmllint cannot verify it and a wrong mount would fail silently at runtime instead of loudly in the audit. Parent-chain bindings are a documented trap in this port for exactly that reason. The padding is now a property each mount site sets. Cortinilla.qml is qmllint-clean. Found by running qmllint over the files this branch touched, after two identifiers were written before being verified against the source - a Flickable forceLayout() that does not exist, caught by the smoke, and a tracksQuery property that does not exist, caught by reading. Neither the resolution audit nor the singleton xref can see this class of error: they check component types and BRIDGE members, not view-local properties. qmllint does, and it is now part of the gate. cc |
||
|
|
4806584ee2 |
feat(qt): local "View more" routes into the LocalLibrary tabs
The three local sections' View more links now leave the search surface entirely and land on the matching Local Library tab. Sending them to the Qobuz results page instead would show a page with no on-device content at all, which reads as the link having done nothing. local-album -> Albums tab local-artist -> Artists tab local -> Tracks tab, pre-filtered with the live query The query pre-filters the TRACKS tab only. Albums and artists have no search box of their own, so handing them a query would be a lie - the list would come back unfiltered while something claimed otherwise. The active tab is QML-owned state, so the route is a pending property the view consumes, mirroring the localPendingArtist mechanism that already existed for "go to artist" on a local album. It is released after being applied, because the property CHANGE is the trigger and an unreleased route could not fire twice in a row. The consumer also runs on mount, so a route set while the view was unmounted still lands. Setting the tracks filter writes the view's own search state as well as issuing the query, or the search box would render empty next to a filtered list. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C9 (sections 3.4-3.5). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
7e58f2f43c |
feat(qt): cortinilla local row click routing
Local rows route through the local seams, never the Qobuz ones. Their ids
live in a different space entirely: an album id is a group key, a track id is
a library row id, and an artist row has NO id at all. Handing any of them to
the catalog routes would 404 or, worse, open a stranger's album that happens
to share the number.
- album -> the local album view, by group key.
- artist -> the LocalLibrary Artists tab BY NAME, because there is no id to
route by.
- track -> played from the per-query snapshot.
Playing from the snapshot rather than re-resolving the id is deliberate: the
snapshot is the exact vector the payload was built from, so the queue matches
what the user is looking at - including the Plex rows that were prepended,
whose order a fresh lookup would not reproduce. The queue is the WHOLE
fetched vector starting at the clicked row, which is the reference's
behaviour: the dropdown shows three tracks, but activating one gives a real
queue instead of an orphan.
The position lookup is STRICT. If the clicked row is not in the snapshot it
logs and does nothing; falling back to index 0 would silently play a
different track than the one under the cursor, which is worse than not
responding.
Local rows stay out of the learned ranking, which the existing guard already
handled - it was dead code until now because no local rows existed. The
reason is the same id-space problem: a bucket keyed on a local id could never
be matched again, and a local artist has no id to key on.
Playlist rows are no longer inert. The note calling them impossible said
there was no playlist view in the port; open_playlist has existed and
PlaylistView.qml ships, so the row now opens the playlist like every other
surface does. PARITY-DEBT row 28 loses one of its sites.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C8
(sections 3.1-3.3, 3.6, R7-2).
Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
15610bc7e9 |
feat(qt): desktop cortinilla local sections
THE deliverable. The header dropdown returned Qobuz results only; the reference also returns on-device results. It now does too. live() runs the two halves CONCURRENTLY with tokio::join!: the Qobuz search and the local-library query. Neither waits on the other, which is what makes a slow or unreachable Qobuz still produce a usable dropdown. The local half appends three sections LAST - Albums, Artists, Tracks - with Plex unioned in and prepended, and the append re-runs assign_flat_indices so the local rows get contiguous indices after the Qobuz ones. Caps widen offline or on an unauthenticated session, from 3/2/3 to 8/4/8: with no Qobuz half the dropdown IS the local library, so a compact block would waste the panel. The session is read on the synchronous side, before the debounce, so it describes the session at the keystroke. A Qobuz failure now DEGRADES to a local-only payload instead of discarding everything. The old Err arm returned, which is what made the whole dropdown vanish on a network hiccup; the reference's loader has no ? and cannot return Err at all. LAST_CORT_LOCAL keeps the raw rows in lockstep with the payload. The click router cannot re-resolve a track from a row id - a local artist row has no id, and a local album row's id is a group key - so it indexes the snapshot. Both offline early returns are gone. search_live returned before doing anything when offline, justified by a note saying there was nothing to show; that note was the reason this feature stayed invisible, and it is now false. search_submit returned too, which left the PREVIOUS results page on screen, so Enter offline looked like a dropped keystroke. Also lands the Qt half of the cache deletion (S1b). store() persisted into the Intelligent Search result cache, which NOTHING has ever read: SearchService::cached has zero non-test callers in either frontend and git log -S shows the read side was never wired in any commit. Every keystroke paid a full serialize and fs::write of a multi-megabyte artist store, on the calling thread, inside the mutex the UI thread contends for, to feed a file nobody opens. Removing the writer cannot change any rendered output. The Slint half is commit S1a on branch S; this one lands now so the Qt binary stops paying it without waiting for a merge. The immersive dropdown is UNCHANGED and stays deliberately different: albums only, no tracks, no top result, no View more, and selecting a row acts on playback rather than navigating. It shares only the local ALBUM mapper. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C7 (D1-D26, J1, J2, S1b, R7-1, R7-3). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. AC-27: no store in search_qt.rs. cc |
||
|
|
a7e64c0bdf |
feat(qt): search_local.rs - the local cortinilla mappers and loader
search_qt.rs already hosted three controllers and was well past the size
budget, and both dropdowns need these mappers. The immersive arm shipped
first with its own copies of LocalCaps, local_album_artist,
derive_local_album_rows and append_immersive_local_albums; those MOVED here
rather than being duplicated, and the immersive arm now calls the shared
ones. Pure and blocking helpers only - no Qt types, no bridge, no ui().
New, ported from the reference and not yet wired (C7 does that):
- derive_local_artist_rows. Grouped by canonical album-artist,
case-INSENSITIVELY, where the album grouping is case-SENSITIVE. That
asymmetry is the reference's and is reproduced, not smoothed. id is left
EMPTY on purpose: local artists have no id anywhere, so routing is by
name - which is exactly why C5 had to stop hasTop testing id emptiness.
- map_local_track_to_cort_row, with the "artist · album" subtitle and its
fallbacks.
- append_local_sections: Albums, Artists, Tracks appended LAST, section
kinds local-album / local-artist / local so View more routes to the right
LocalLibrary tab, while per-ROW kind stays album/artist/track so the
thumbnail shape and the click router behave. Empty sections are skipped.
- local_art_split, factored out of the album builder so all three row
builders share one artwork rule.
- load_cortinilla_local(query, limit, gated), re-scoped from load_imm_local
with the intelligent-search gate the main cortinilla needs; the immersive
arm passes false because it has its own enable.
Two fixes ride the re-scope. exclude_network_folders was a literal false and
is now the exact twin of the reference - connectivity confirmed Down, not
merely "offline", because a manual offline toggle with the LAN up must keep
NAS content visible. This fixes the cortinilla's call site only; four other
Qt call sites still pass false and PARITY-DEBT #82 stays open. And the path
now carries a [qbz-qt][perf] line: the port had none on any local path,
which is why the cost of this query on a large library was unmeasured.
The module header records the reference behaviours reproduced deliberately:
no dedupe between the Qobuz and local halves, window-relative has_more, Plex
prepended so it can starve the local-file tracks, and no folder-cover
fallback.
Three dead-code warnings are expected and are exactly the C7 seam:
derive_local_artist_rows, map_local_track_to_cort_row, append_local_sections.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C6.
Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
263b82a694 |
fix(qt): cortinilla robustness - scroll latch, hover, hasTop, index guard
Six defects, plus one owner-requested behaviour change.
- The document is now applied imperatively instead of bound. cortinillaJson
is republished wholesale when the artwork pass lands, about 1.5s after
the rows, and a bound Repeater model made that a full model reset: every
delegate destroyed and rebuilt, contentHeight momentarily 0, and
StopAtBounds clamping contentY to 0. The user was yanked back to the top
mid-read. The offset is latched and restored via Qt.callLater - NOT
forceLayout(), which is a QQuickItemView method and does not exist on a
plain Flickable; calling it threw a TypeError and clamped against a stale
contentHeight.
- panelHovered could latch true forever: if the panel went invisible while
the pointer was over it, the hover source never reported false again.
- Hover detection moved from a MouseArea to a HoverHandler. The MouseArea
was declared first, so the Flickable and every row's MouseArea sat on top
of it and took the hover events - containsMouse stayed false across most
of the panel, which is why hovering did not hold the dropdown open. A
plain MouseArea stays behind it purely to swallow background clicks.
- hasTop tested the top row's id for emptiness. Local ARTIST rows carry an
intentionally empty id - routing is by name - so the top-result block
would have vanished for them once the local sections land. It now trusts
the Option the payload already models.
- The row click passed flatIndex raw across the i32 boundary, where
undefined coerces to 0 - the top result's index - so a malformed row
would activate the wrong thing rather than nothing. The highlight already
guarded; the action now does too.
- The nested Repeaters both used an unqualified modelData. It resolves
correctly today, but one added `required` on the inner delegate would
silently flip every row to render its section. Both are named now.
- LAST_CORT's locks no longer unwrap(). row_clicked and move_selection are
invokable bodies, i.e. Qt GUI thread, so a poisoned lock there would take
the UI down over a snapshot that is stale at worst.
OWNER DIVERGENCE (2026-08-03, unplanned): hovering the panel no longer
pauses the idle timer indefinitely - it stretches it to 30s. The reference
pauses outright, which means a cursor left over the panel by accident holds
it open forever. 30s satisfies both halves of the owner's ask: reading is
never interrupted at 4.5s, and a forgotten cursor still lets it go.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C5
(D-BUG-6/9/10/11/12/13).
Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
4e1703c436 |
feat(qt): reduceMotion seam and a coarse skeleton tick
The owner ruled 3-C: build the seam here so the kiosk port consumes it instead of having to invent it. The earlier plan was to wait for kiosk to define it, but kiosk is not implemented - it is a contract - so "wait for kiosk" meant waiting on a date that does not exist. The shape is no longer a guess, which was the only objection to building it early. Two independent sources fix it: the kiosk contract mandates "FORCED reduce-motion while in kiosk", and the reference computes the flag as kiosk_profile || !use_gpu_renderer. QbzShell.reduceMotion is derived, never a preference - do not add a Settings row for it. The existing play-indicator toggle is a preference and a different thing; binding them would make "animate the play indicator" change this dropdown's pulse rate. Only the kiosk half is wired, and the omission is deliberate rather than partial: the renderer tier has no honest source here. It is a QML-side fact, this port runs on the GPU in every real session, and the one place a probe would report software is the offscreen smoke, which forces it by definition. Wiring it would turn reduce-motion on exactly where it means nothing. The kiosk literal is a named const with a pointer to their contract; flipping it is one line on their side. The cortinilla skeleton is the ONE consumer: its single host Timer drops from 700ms to 2000ms. No new timer, no per-delegate animation - the reference's own note records that this pulse was the main cost of opening the cortinilla on weak GPUs. Giving that Column an id also fixes a real scope bug on the way past. The Timer read an unqualified `parent`, and Timer derives from QtObject, which has no parent property - so it resolved up the scope chain, out of the block entirely. The sibling rectangles' parent.parent[.parent] chains are now explicit too. EqualizerBars.qml and the immersive contract's D11 both recorded that Qt had no reduce-motion twin. Both are corrected: the seam exists, those surfaces are simply not consumers yet, and wiring each is its own one-liner. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4b (section 5.g G1-G5, ruling R10; D-BUG-1 folded in). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. AC-39: exactly one behavioural consumer. cc |
||
|
|
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 |