Commit Graph
6175 Commits
Author SHA1 Message Date
vicrodh 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
2026-08-11 11:26:50 -06:00
vicrodh 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
2026-08-11 09:00:01 -06:00
vicrodh 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
2026-08-11 08:59:36 -06:00
vicrodh 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
2026-08-11 01:38:18 -06:00
vicrodh 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
2026-08-11 01:38:18 -06:00
vicrodh 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
2026-08-10 22:08:43 -06:00
vicrodh 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
2026-08-10 22:08:43 -06:00
vicrodh 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
2026-08-10 20:51:38 -06:00
vicrodh 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
2026-08-10 20:24:25 -06:00
vicrodh 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
2026-08-10 20:23:45 -06:00
vicrodh 98dfcd8173 fix(qt): nav flyout entries land on their own tab
Every flyout entry (Discover > For You, Library > Albums, Local >
Folders...) navigated to the section but landed on its default tab: the
port handed the tab to the mounted view through a depth-capped scene
search (NavFlyout.findTabHost, cap 7), and the ContentRouter extraction
(kiosk port D3) added a mount level the walk never reached — flagged as
a latent desktop regression in the kiosk audit (2026-08-02
AUDIT-REPORTS.md, finding 11) and confirmed by smoke test.

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

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

km
2026-08-10 18:57:02 -06:00
vicrodh f24da6c784 i18n: catalog strings for the queue drop prompt, folder editor, Plex PIN, log viewer, export auth gate and offline recheck
cc
2026-08-10 17:17:55 -06:00
vicrodh 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
2026-08-10 17:17:43 -06:00
vicrodh 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
2026-08-10 17:17:43 -06:00
vicrodh 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
2026-08-10 17:17:28 -06:00
vicrodh 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
2026-08-10 17:16:46 -06:00
vicrodh 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
2026-08-10 17:16:30 -06:00
vicrodh 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
2026-08-10 17:16:18 -06:00
vicrodh 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
2026-08-10 17:15:53 -06:00
vicrodh 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
2026-08-10 17:15:18 -06:00
vicrodh 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
2026-08-10 17:05:56 -06:00
vicrodh 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
2026-08-10 17:05:42 -06:00
vicrodh 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
2026-08-10 17:05:42 -06:00
vicrodh 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
2026-08-10 17:04:51 -06:00
vicrodh 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
2026-08-10 17:04:38 -06:00
vicrodh 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
2026-08-04 17:02:09 -06:00
vicrodh 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
2026-08-04 16:39:00 -06:00
vicrodh 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
2026-08-04 16:37:30 -06:00
vicrodh 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
2026-08-04 14:56:37 -06:00
vicrodh 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
2026-08-04 14:55:36 -06:00
vicrodh 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
2026-08-04 14:24:51 -06:00
vicrodh 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
2026-08-04 14:05:05 -06:00
vicrodh 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
2026-08-04 13:51:19 -06:00
vicrodh 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
2026-08-04 13:51:19 -06:00
vicrodh 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
2026-08-04 12:50:01 -06:00
vicrodh 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
2026-08-04 11:23:54 -06:00
vicrodh 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
2026-08-04 11:23:41 -06:00
vicrodh 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
2026-08-04 11:23:07 -06:00
vicrodh 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
2026-08-04 09:41:37 -06:00
vicrodh 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
2026-08-04 08:43:02 -06:00
vicrodh 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
2026-08-04 08:42:28 -06:00
vicrodh 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
2026-08-04 08:42:03 -06:00
vicrodh 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
2026-08-04 05:21:10 -06:00
vicrodh 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
2026-08-04 04:15:09 -06:00
vicrodh 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
2026-08-04 02:47:56 -06:00
vicrodh 0d6e7d3498 feat(qt): QbzMini bridge, mini queue projection and mini prefs
Block B1 of the miniplayer/tray contract. No UI: this lands the state the
five surfaces will bind to, so B2 can mount a window against a bridge that
is already correct.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cc
2026-08-03 23:59:42 -06:00