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
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
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
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
- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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