36e8ec990d8a33285ffddf570ff4c69e07b43bf3
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
36e8ec990d |
fix(qt): serialize DNS A/AAAA lookups (RES_OPTIONS=single-request)
This host's path to its first resolver drops the second of two parallel DNS queries on one socket, so every getaddrinfo (parallel A+AAAA) stalls for the full 5s RES_TIMEOUT before retrying — the multi-second shuffle-start tax after the app idles, confirmed by the CMAF init timing (send=5449ms, body=0ms) and reproduced app-free with getent + raw UDP probes (parallel loses AAAA; sequential passes in 0.19s). Setting RES_OPTIONS first in main makes glibc serialize the lookups process-wide. No-op on non-glibc resolvers. km |
||
|
|
a561e7ae28 |
fix(player): cancel the superseded track's CMAF segment feeder
The segment feeder spawned by play_track kept downloading to completion after the user switched tracks, holding the link: a shuffle&play pressed mid-stream measured 5441 ms for the new track's init segment (vs 423-760 ms on an idle link) because the init's connect/TLS/TTFB queued behind ~15 MB of in-flight segments of a track nobody was listening to. begin_play (every superseding intent: play_*, stop) now cancels the registered feeder cooperatively via a watch flag the feeder selects on; the in-flight request is dropped immediately. The abandoned buffer is deliberately NOT errored or completed at cancel time, so the outgoing engine keeps playing what is already buffered until the audio thread installs the new source; the buffer is sealed (complete) once the new source is in place (or on stop) so a parked reader wakes to EOF instead of hanging. Cancelled partial tracks are not cached. km |
||
|
|
63b4082e6a |
feat(qobuz): split CMAF fetch timing into send vs body
Diagnoses where slow init-segment fetches lose their time: send(dns+connect+tls+ttfb) vs body. Landed the 2026-08-10 proof that the multi-second shuffle-start stall sat entirely in the connection phase (send=5449ms, body=0ms). cc |
||
|
|
740adf55d1 |
fix(player): clear stale gapless pending on PlayStreaming + lifecycle trace
The PlayStreaming arm was the only start path that did not clear gapless_pending/gapless_request_armed on supersede (Play, PlayDsdDop, PlayDsdNative, Stop and Seek all did). A pending that outlives its engine both blocks gapless re-arming for the new track and fires positionally at the new track's end, swapping current_track_id to a track whose engine no longer exists — observed as the queue stopping dead with a full queue. Also adds [gapless-trace] set/clear logging at every lifecycle point to pin down the surviving X -> X self-transition. cc |
||
|
|
60b8dd13f2 |
test(qt): make the hotkey display test platform-aware
format_display branches on cfg!(target_os = "macos") — glyphs joined by a space there, words joined by " + " everywhere else — but the test pinned only the non-mac spelling, so it could never pass on the Mac. It failed the first time the suite was ever run there. The code was right; the assertion was one-sided. Branch it, and keep the modifier-free cases unbranched since those are what actually pin the glyph table the test is named for. cc |
||
|
|
024ed615e8 |
fix(qt): MainThreadMarker::new() needs objc2-foundation's NSThread feature
Second one the Mac build caught and Linux could not: the type resolved, but `new()` did not exist — it is gated on `NSThread` (objc2-foundation-0.2.2/src/thread.rs:210-213), leaving only the unsafe `new_unchecked`. That is precisely the wrong half: the point of the call is to ASK whether we are on the AppKit main thread and no-op when we are not. cc |
||
|
|
1b18adcde0 |
fix(qt): MainThreadMarker comes from objc2-foundation in this generation
The Mac build caught it on the first try, which is exactly what it was for: `use objc2::MainThreadMarker` does not resolve — in the objc2 0.5 / objc2-foundation 0.2 generation the type is exported by FOUNDATION (objc2-foundation-0.2.2/src/lib.rs:152, ungated); it only moved to the objc2 root later. The reference imports it from the same place (crates/qbz/src/tray/macos.rs:38), which is where this should have been read from in the first place rather than assumed from a newer API. The direct `objc2` dependency goes with it: nothing here names a type from that crate. cc |
||
|
|
3b3ff54964 |
feat(packaging): ship an optional KWin rule to keep the miniplayer above
Measured, not assumed: under XWayland the same Qt hint DOES set _NET_WM_STATE_ABOVE and KWin honours it, so always-on-top is not impossible — it is only impossible for the app to ASK for it on Wayland, where no such request exists in the core protocol. So the app stops claiming it can pin itself and instead ships the thing that works: a window rule matching the miniplayer by its stable, never-translated title, narrowed by wmclass so no other app's window picks it up. The main window is titled "QBZ", which is why the title match is exact rather than a substring. OPTIONAL and never applied by an installer. It is the user's window manager and their configuration; a package that silently rewrote kwinrulesrc would be a worse citizen than one that ships a file and a sentence of documentation. The rationale, the two rejected routes and the import steps are in qbz-nix-docs/qt-frontend/2026-08-04-miniplayer-keep-above-kwin.md. cc |
||
|
|
dc604bdf62 |
feat(qt): wire the macOS Hide-Dock setting — Spotify behaviour on close
The row rendered, persisted and drove nothing, which is the exact defect class the miniplayer/tray contract existed to end. The owner named the behaviour they want: close puts the app in the menu bar AND drops the Dock icon; minimize still goes to the Dock like any app. So only the CLOSE path touches the policy. The wiring point is `QbzTray.setWindowShown`, which QML calls from exactly two places — `hideToTray` and `showFromTray` — so this needs no third visibility owner and cannot drift from the real window state. Hiding drops the icon only when the setting is on; SHOWING always restores it, with no setting check. A user who turns the option off while the app sits in the menu bar must still get their icon back, and restoring an icon that is already there costs nothing. NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind cfg(target_os = "macos"), and a cross-check dies in the C build scripts for want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2 instead — which immediately earned its keep: `NSApplicationActivationPolicy` lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication feature does not pull it. The three features here match the reference's own proven set. First real check is a build on the Mac mini. cc |
||
|
|
8bc32a91a3 |
fix: MPRIS Position is now exact at READ time, not as fresh as the last push
The owner asked whether the remaining ~1 s was the sweet spot. It was not — it was an artefact of fixing this with a push instead of a getter. MPRIS `Position` is read on demand, so what matters is the answer when the client ASKS, not when the app last spoke. The backend stored a snapshot, so the answer was only ever as fresh as the last push, and the only lever left was polling faster — which would have meant a second timer the perf rules refuse. Storing the instant alongside the value removes the tradeoff: the getter returns `position + elapsed` while Playing, exact to the microsecond however rarely the app pushes. The 1 Hz push stops being the clock and becomes what it should have been all along — a re-anchor against drift. Every write re-stamps, including a status change that carries no position: without that the base would keep running across a pause and the clock would count while the music did not. cc |
||
|
|
7ba07ec6f8 |
fix: emit MPRIS Seeked, so an already-open widget stops extrapolating
The owner's observation is what separated the two halves of this: "el contador del seekbar del widget solo empieza a contar hasta que lo abro." A client reads Position ONCE, when it opens, and extrapolates from there — it does not poll. So two different things were broken and only one was fixed by keeping the stored position fresh. Fresh storage fixes what a client sees when it OPENS. `Seeked` is the only thing that reaches one that is ALREADY open: it tells the client to stop extrapolating and re-read. Without it, a seek moved the music and the widget kept counting from where it had been. So the seek path emits Seeked instead of re-asserting the playback state, which was the previous commit's guess at a cure inside an API that had no signal. `mpris_server::Signal::Seeked` was there all along. Defaulted on the trait to `set_position`, so a backend without the signal still gets the value right on the next read and no other consumer changes: cargo check passes for qbz-app and qbzd. cc |
||
|
|
f45b703c64 |
fix: MPRIS reported a frozen position — Position is read on demand
The owner measured QBZ at 4:03 with Plasma's widget stuck at 0:14. The earlier seek fix was right about seeks and wrong about the shape of the problem: the position was not drifting, it was FROZEN. MPRIS `Position` is a read-on-demand property — the spec deliberately keeps it out of `PropertiesChanged`, so a client reads it when it wants it. The Linux backend serves whatever it last stored, and it only ever stored a position when `set_playback` carried one: at a track start (zero) and at a play/pause edge. Between those, every read returned the same stale number for as long as the track ran. This is in the SHARED crate, so the shipping Slint build has it identically — nobody had watched the widget's clock there. `MediaIntegration::set_position` stores and emits nothing, so the Qt poll loop refreshes it on the tick it already computes the position for: one channel send per second, no bus signal, so it does not become the per-tick work the perf rules refuse. The method is DEFAULTED, so no other consumer has to change — `cargo check` passes for qbz-app and qbzd, and the Slint frontend needs no edit (it cannot be built here, per the qbz-ui wall, so it is verified statically as R4 requires). cc |
||
|
|
0372b4d66f |
fix(qt): the mini footer sat 1px outside the card on every side
Measured on the owner's screenshot rather than estimated: the artwork and meta rows span 366 px, the seek and transport rows 368. Qt draws a Rectangle's border INSIDE its bounds, so the card's visible fill is already 1 px in on each side — but the footer was anchored to the card's outer edges and painted straight over the hairline, so the outline stopped exactly where the footer began. Micro looked right because there the footer IS the card and there are no two layers to misalign. Inset by the border width on the three edges it touches, with its corner radius reduced to match so the curve nests inside the card's instead of racing it. No gap opens above it: the surface area still ends at the old boundary, so the two now overlap by a pixel rather than parting. cc |
||
|
|
b7706bdf38 |
fix(qt): drop always-on-top, pin the mini's size, and report seeks to MPRIS
Four things from the owner's smoke, all polish, all measured on real Plasma Wayland rather than inferred. ALWAYS-ON-TOP IS GONE. It shipped as owner ruling K1's default — new work the reference promises in three comments and implements nowhere — and it does not work: a Wayland client cannot raise itself above other clients. There is no such request in the core protocol; it takes a compositor policy or a KWin rule, and Qt has nothing to send. The owner's call was explicit, so the button, the property, the invokable and the pref are all removed rather than left as a control that lies. The capsule is nine buttons again. THE MINI'S SIZE IS NOW FIXED PER SURFACE, min == max. A Plasma tiling shortcut resized the mini to half the screen, the debounced persist wrote 1720 px into the shared ui_prefs.json, and every expanded surface opened giant from then on — with no way back, because this port never gave the mini a resize handle. So the only thing that could ever write those prefs was the window manager, i.e. the bug. The owner asked for exactly this: force the sizes even when the OS says otherwise, stay sensitive to position. min == max gives both — a compositor may still move the window; a resize is refused. That drops the reference's resizable expanded mini and the persisted pair with it (A-6b / AC-15). `saveGeometry` and `geometry_to_persist` stay, with UT-3: they are the right rule for the day a real resize handle exists, and deleting a tested guard to re-derive it later is how this comes back. The micro capsule now centres on the CARD, not on its 17 px header. The reference centres it on the header, but in micro that header is a strip at the top of a 45 px card while the capsule is 26 px tall, so it sat visibly high. A SEEK NOW REPORTS ITS NEW POSITION TO MPRIS. Position was only refreshed on the play/pause edge, so a desktop widget extrapolated from a base the seek had invalidated — the owner measured ~40 s of lag. The shared crate exposes no Seeked emitter and set_playback already carries a position, so the seek re-asserts the state at its target. The shipping Slint build has the same gap; closing it there is a shared-crate conversation, not this one. Not fixed, and it cannot be here: the mini does not remember its POSITION. Wayland gives a client no way to place itself. Contract ruling K4 said so; the owner's session confirms it. It would work on X11. cc |
||
|
|
d003d67b30 |
fix(qt): the mini footer squared the card's rounded corners
The owner photographed it against a white background: every mini surface rounded on top and SQUARE at the bottom, and the micro card — the smallest of them — square on all four. Their read was right: "los NPB son cuadrados y no tienen transparencia, así que aunque el fondo tenga esquinas redondeadas, el npb prevalece". The footer is an opaque Rectangle anchored to the card's bottom edge, and the card cannot round it: QML's clip is RECTANGULAR. theme/RoundedImage.qml records that as measured fact on this Qt build — "the child paints square over the rounded fill" — which is the same reason the artwork backdrop needed a mask two commits ago. So the footer rounds its own corners, with per-corner radii (Qt 6.7+; this tree builds against 6.11): the bottom two in every mode, and all four in micro, where the footer IS the whole card. That last case is exactly why the smallest surface was the one with no rounding at all. cc |
||
|
|
721841a4b8 |
fix(qt): Qt.quit() was vetoed by our own windows, so Quit never quit
The owner reported it three times and their second wording was the whole
diagnosis: "Quit QBZ debe cerrar la aplicación, NO SOLO LA VENTANA."
ROOT CAUSE. `Qt.quit()` in Qt 6 is a REQUEST, not an order. It posts
QEvent::Quit, and QGuiApplication answers it by calling close() on every
top-level window that has a platform handle — hidden ones included. If any
window refuses its close event, the quit is silently cancelled. Two of ours
refuse:
1. Main.qml's closeOrHide refuses whenever trayLive && closeToTray, and
hides the window on the way out. That IS "cierra la ventana, no la app".
2. MiniWindow's onClosing refuses unconditionally, because for the mini a
close means "leave the mini" — so once the mini had ever been opened,
quit was blocked regardless of any setting.
It also explains "Close to tray does nothing": with the setting ON, the X
hid (correct) and Quit hid too (the bug), so the two were indistinguishable
and the toggle looked inert. Nothing was ever wrong with that plumbing.
FIX. Both quit sites call Qt.exit(0), which exits the event loops directly —
no QEvent::Quit, no window to veto it, and no need to touch the miniplayer's
onClosing, whose refusal is correct for what it means. A one-shot 5 s
watchdog thread, armed only at the three quit sites, flushes the log and
_exit(0)s if anything downstream ever wedges; it cannot fire in a live
session. main() now names each teardown step, so a future hang says which
statement it hung on instead of going quiet.
closeOrHide reports its decision through the bridge — tray_live,
close_to_tray and the arm taken — because a report of "the tray does
nothing" was not previously separable from "the signal arrived and QML
ignored it".
MEASURED, with waitpid on a child rather than /proc (a zombie reads as alive
and that produced a false diagnosis earlier in this session). Old binary:
alive ≥25 s with the window visible, hidden, and with the mini open. Fixed:
exit 0 in 0.35-0.68 s across eleven driven scenarios — the tray Quit in all
three window states, the drawn X and the app-menu Close with the setting
both ON and OFF, the mini's own close button, and the MPRIS Quit method.
One arm remains static-only: a real WM close event with close-to-tray OFF.
VNC delivers no such event, so the owner's first titlebar X is its first
real execution.
cc
|
||
|
|
ce7f29ff9c |
chore(qt): ship the binary as qbz, and log the first MPRIS metadata push
The artifact is now `target/<profile>/qbz` (owner, 2026-08-04). The package stays `qbz-qt`, so `cargo build -p qbz-qt` is unchanged. ⚠ `crates/qbz` — the Slint binary — declares the SAME bin name and shares this workspace's target dir, so both write `target/<profile>/qbz` and the last build wins. Survivable only because the Slint binary is never built in this worktree (the qbz-ui 20-30 GB wall); if it ever is, a smoke against "the wrong qbz" is silent. Written into Cargo.toml beside the name. The MPRIS line exists because "Plasma sees nothing" has two very different causes — the player never registered, or it registered and no metadata was ever pushed — and the log could not tell them apart. It settled that question the same day: the owner's log shows `[mpris] metadata pushed: Holy Diver — Dio (350s)`, so the port's half works and what remains is desktop-side. cc |
||
|
|
684fffd7f8 |
feat(qt): the mini queue and lyrics surfaces
Block B4, the last code block of the miniplayer/tray contract. MiniQueueSurface — the current track first, then the FULL upcoming list: uncapped, unpaginated, a position number where the main list has artwork, and hover beating active on the row background. A recycling ListView, since a 2,000-track queue in a 380 px card is exactly the case the track's perf rule forbids materialising. Its document is applied IMPERATIVELY, and that is the perf rule, not style. A bound model hands the view a new array on every publish that differs, which is a full model reset: a user scrolled to row 120 gets yanked to the top every time the track advances. The upstream guard only suppresses IDENTICAL republishes — a real change is precisely what it lets through. The cure is the port's own, from Cortinilla: latch contentY, apply, restore under Qt.callLater so a burst costs one restore. MiniLyricsSurface — the empty test is on the line COUNT, not on status; there is no loading, error or offline state in the mini. It mounts its own LyricsSyncEngine, which is what makes the reference's ensure_lyrics_engine hack unnecessary: the karaoke advances with the sidebar panel closed. Both surfaces are gated on QbzMini.open, not on the surface alone. The mini window is created once and never destroyed, so a surface-only gate would leave them mounted and re-parsing a whole JSON document on every queue or lyrics edit for the rest of the session with the mini nowhere on screen. The lyrics pump additionally reads the MINI WINDOW's visibility. Section 4.5 spells the gate as open + surface + synced, but QbzMini.open is a logical flag that stays true when the window is minimised, and the general rule says that gate must read the mini's own window. The window term is added, not substituted. queue_play routes remote-first and RETURNS on Err — an error must never fall through to local playback — and its row-to-index mapping calls the tested helper rather than restating it. Row 0 resumes only when a current track exists; with none it plays upcoming[0], which is the reference's off-by-one this port declines to inherit. mini_qt::clamp_to_implemented is DELETED. It was the temporary guard that kept an unimplemented surface from stranding the user on a blank card, and its doc said the last block to land would remove it. Driven and seen: ten rows with their numbers, titles, artists, durations, the E badges on the two explicit tracks, and no artwork. What is NOT seen, because it needs sound: a row click actually playing, the off-by-one on live behaviour, and the karaoke advancing. cc |
||
|
|
6b17db90dd |
feat(qt): the mini footer, the hover capsule and always-on-top
Block B3. The owner's verdict on the four blocks that landed before it was that the miniplayer was "no draggeable, no hay controles de ningun tipo. Ni de reproducción, ni para cambiar entre modos, no es pinneable." All of that lived here. qml/miniplayer/ — MiniFooter with its three modes, and the five primitives the reference declares as inner components: MiniSeek, MiniTransport, MiniVolume, TBtn and CapBtn, one file each because a 396-line Slint footer folded into one QML file lands well past the size rule. MiniWindowControls is the hover capsule: five surface tabs, a separator, then expand, pin, backdrop, drag and close, plus the collapsed square trigger. The transport in full and compact is centred by a PHANTOM SPACER the width of the volume button, not by a centred layout — micro has no volume and is the one mode genuinely centred. The seek carries the reference's pending machine: the -1.0 sentinel, the 0.02 convergence window and the 8s watchdog. The capsule is a conditional MOUNT, so leaving the card destroys its expanded flag and its 250ms timer with it. ALWAYS-ON-TOP is new work, not a port: three comments in the Slint tree promise it and no code implements it. The owner asked for it back because Tauri had it and Slint structurally cannot. Qt.WindowStaysOnTopHint, a pin button, and a persisted pref that defaults ON. COLOUR LITERALS: six were carried over from the reference verbatim and every one of them was WRONG. Slint writes #RRGGBBAA; Qt parses #AARRGGBB, so #ffffff33 is not white at 20% but opaque pale yellow — which is exactly how the capsule rendered until it was driven and looked at. This is the second time this trap has landed in this port. Each converted literal now carries the reference form beside it. One of the six is MiniShell's, from B2. mini_qt.rs — surface 0 leaves the temporary clamp, because the micro footer IS the card and now exists. Surfaces 3 and 4 stay clamped until B4. Driven and seen, not inferred: the footer in artwork and micro, the capsule revealing on hover and collapsing on leave, the micro card at 380x57 with its centred transport, and the pin persisting mini_always_on_top across a toggle. The drag handle is wired to startSystemMove on pressed; the VNC platform cannot move a window, so that one is owed a real compositor. cc |
||
|
|
5dd450fb9f |
feat(qt): MPRIS — the desktop can finally see the player
Owner ruling K3 kept MPRIS out of the miniplayer/tray contract, and its default shipped. The owner reversed it after smoking the tray: QBZ did not appear in KDE Plasma's now-playing widget. Plasma reads MPRIS, so without this the desktop cannot see the player at all. src/media_controls_qt.rs (new) — a plain module over the SHARED qbz-media-controls crate, following the qbzd precedent rather than the Slint one because qbz-qt is also not a Slint app. Outbound: metadata on the track edge, playback state on the play/pause edge, both carrying position. Inbound: play, pause, toggle, next, previous, stop, seek and volume, plus Raise — which calls tray_qt::present() unchanged, the mini-aware verb the tray contract shaped for exactly this caller. src/artwork_qt.rs — cached_raw_path, the remote arm of cached_path without its file_url step, so the art URI is built for MPRIS rather than for QML. src/playback_qt.rs — four sibling pushes beside the tray's: both arms of refresh_now_playing, and BOTH is_playing != was_playing blocks. One of two is the silent half-port, so the grep is the instruction. Verified on the session bus, not by reading: qbz-qt owns org.mpris.MediaPlayer2.com.blitzfc.qbz, Identity "QBZ", DesktopEntry com.blitzfc.qbz — that is what gives Plasma the icon — with CanControl, CanPlay, CanPause, CanGoNext, CanGoPrevious, CanSeek, CanRaise and CanQuit all true and PlaybackStatus tracking. Metadata is empty until something plays, and that half is NOT verified: it needs audio. cc |
||
|
|
a64c20b09f |
fix(qt): never let the shutdown path outlive the UI, and log the tray hop
An owner smoke reported no way to close the app but killing the process. That was not reproduced here — driven through D-Bus exactly as a panel does it, the tray's Quit exits in 0.4s and the app-menu Close with close-to-tray OFF exits in 0.5s, both code 0 — so this is insurance and instrumentation, not a claimed cure. The insurance: once the event loop is gone the process has no UI, so nothing on that path may block without a bound. The cast shutdown ran unbounded and takes a tokio Mutex the cast poll task also holds, aborted only AFTER the lock is acquired. Two seconds is the cap; a cast device that keeps playing that much longer beats an app that cannot be closed. The instrumentation: tray_qt logs when it SENDS, and nothing logged on the QML side, so "the tray does nothing" could not be told apart from "the signal arrived and QML ignored it". The three handlers now say which. That ambiguity cost a diagnosis today; one line each ends it. cc |
||
|
|
088387f2c1 |
feat(qt): close-to-tray across every exit, and the macOS tray settings rows
Block B6 of the miniplayer/tray contract. B5 landed a tray whose clicks reached Rust and stopped there; this is the other half. qml/Main.qml — hideToTray(), showFromTray() and the ONE close choreography every exit now calls, plus the handlers for the tray bridge's four signals and the mini's close-app signal. The close predicate is both conditions, trayLive && closeToTray: with the tray off a close QUITS, because gating on the setting alone strands the process with no window on a desktop that has no SNI host. close.accepted = false happens before the hide — hiding never arms Qt's quit check, an accepted close does. The miniplayer's enter and exit are rewired onto those two verbs, which is what makes the tray toggle honest: the reference hides the main window behind the tray's back and its second tray click brings the main window up beside a live mini. THE EXITS ARE FIVE, NOT FOUR. The contract enumerated four and gave the grep that finds them; re-run today it returns a fifth, because the kiosk shell grew its own drawn close button after the contract was written. It goes through the same choreography. Four POC-NOTEs die here. showFromTray also un-iconifies. Qt's hide() preserves windowStates where the reference destroys the surface, so a window minimized before it was hidden would otherwise come back minimized — present in the taskbar, invisible on screen, which reads as the tray having done nothing. qml/settings/AppearanceSettings.qml + src/settings_qt.rs — the three macOS rows: the MENU BAR header swap, the macOS wording on Close-to-tray, and the Hide-Dock row, all absent on Linux. minimize_to_tray gets storage and no row, which is the owner's standing decision. And the tray-close-to-tray write arm now pushes to the bridge beside its persist, so toggling it changes the very next close with no restart. src/mini_bridge.rs — close_app and the signal it routes through, so the mini's red X will honour close-to-tray like every other exit. Its button is B3's, so the chain has no caller yet. Driven, not assumed: with the tray live and close-to-tray just pushed false, the app-menu Close row QUIT the process — which can only happen if the push reached the bridge, since the seed had set it true. And Shift+M then Escape returns the main window pixel-identical, zero of 1.6 M pixels changed, with the mini alone on a black framebuffer in between: the main window really is hidden, and Qt's hide/show preserves its geometry with no restore call. cc |
||
|
|
cd89b40945 |
feat(qt): the miniplayer window, shell and the two display surfaces
Block B2 of the miniplayer/tray contract, and the block that settles the premise the whole port rests on: a second top-level Window in the same QQmlApplicationEngine reads the SAME bridge singletons as the shell. Proven by driving it — a probe window with one Text bound to QbzPlayer.npTitle tracked the main window across a track change with no mirror code. Slint needs ~155 lines of mirroring for this; Qt needs none. qml/miniplayer/ (new, six files) — MiniWindow (frameless, transparent, transientParent null, its own key host and its own onClosing), MiniShell (the card, the artwork backdrop, the surface router), MiniCoverArt and MiniExplicitBadge (the two components MiniBits.slint exports, which cannot share one .qml file), and the compact and artwork surfaces. The card is the window minus a 6 px gutter, its radius is 9 for the condensed surfaces and 10 otherwise, and switching the surface RESIZES THE WINDOW — 540 to 178 and back, driven and measured, not inferred. The backdrop lives inside a masked container. `clip: true` on a rounded Rectangle does NOT round its children on this Qt build — theme/RoundedImage.qml records that as measured fact — so without the mask the scrim and the atmosphere's own opaque root would paint square corners over the card the moment the backdrop is on. That is reachable before its toggle exists, because the pref is co-owned with the shipping Slint app. src/mini_bridge.rs — enter/exit, the mini's own key dispatcher, and toggle(). Re-entering an open mini re-raises it rather than doing nothing, which is what the reference does and what present() will want. src/hotkeys_qt.rs — action_for_mini_key, a SIBLING of action_for_key with the context arm inverted. The main window still refuses Context::Mini and the test that pins it is untouched; a new test pins the mirror. src/hotkeys_bridge.rs — the ui.miniPlayer arm stops logging and toggles. qml/shell/ViewModeMenu.qml — the Miniplayer row goes live with its own dispatch arm, and it is hidden under the kiosk profile, 1:1 with PlayerBarSmall.slint:819. The kiosk port landed first, so that gate was owed here. Three comments claiming the row is inert are corrected. Surfaces 0, 3 and 4 are clamped to the artwork surface until their content exists (B3 and B4), because mini_surface and mini_default_view live in the prefs file shared with the Slint app: a user whose Slint mini was last on Queue would otherwise open onto a blank card with no visible way out. Each block deletes its own entry from that clamp. Enter does not hide the main window yet — window.hideToTray() is B6's, and open-coding a bare hide() here is what the one-visibility-owner rule forbids. So the main window stays visible behind the mini in this commit, by design. cc |
||
|
|
56d4387172 |
feat(qt): system tray via ksni
Block B5 of the miniplayer/tray contract. The tray settings rows have rendered and persisted since the Settings port and driven nothing; this is what they drive. src/tray_linux.rs (new) — the ksni StatusNotifierItem: the twelve icon pixmaps, the variant ladder, the tooltip, the six-item menu, left/middle click and scroll, and the qbz-tray-updater thread every mutation crosses. Note the filename inversion it reproduces: tray-light-* holds the BLACK glyph, so the "mono-light" setting loads tray-dark-*.png. src/tray_qt.rs (new) — the portable half: init's three gates, the window verbs, the 400 ms toggle debounce, the transport dispatchers and in_gamescope(). toggle_window is mini-aware — while the mini is open a tray click raises the MINI, because a mini-blind toggle would put the main window beside a live mini on the first click. src/tray_bridge.rs (new) — QbzTray: trayLive, closeToTray, and the four signals the ksni thread reaches QML through. Rust cannot touch a QQuickWindow, so every verb is a signal. icons/ — the twelve PNGs, copied rather than include_bytes!'d across crates, so this crate does not depend on a file inside the Slint binary crate. src/playback_qt.rs — the three live-update pushes: set_track and clear_track in both arms of refresh_now_playing (the publisher with 23 call sites, so a QConnect or cast track change updates the tooltip too), and set_playing in BOTH is_playing != was_playing blocks, each beside the existing discord_push. src/settings_qt.rs — tray() widened to pub(crate) for the session-entry read; the tray_close_to_tray snapshot default corrected from false to true, which was cosmetic while nothing consumed it and becomes behavioural now; and the tray-icon-theme select arm re-themes the running icon with no restart. Cargo.toml + Cargo.lock — ksni 0.3 with default-features off. Never the tokio feature: the blocking API panics inside tokio and the panic is swallowed, which is why the two dedicated threads exist. src/main.rs, build.rs, qml/Main.qml — the registrations, and tray_qt::init in on_session_entered. That call site is also deliberately the process's first settings_qt::tray(), because the store binds per user and a read from the bridge's construction seed would latch an empty one at the login screen. The Connections block that answers the four signals lands with B6, together with the window verbs it calls. Until then the tray renders, its tooltip and menu are live, and its clicks reach Rust and stop there. cc |
||
|
|
0d6e7d3498 |
feat(qt): QbzMini bridge, mini queue projection and mini prefs
Block B1 of the miniplayer/tray contract. No UI: this lands the state the five surfaces will bind to, so B2 can mount a window against a bridge that is already correct. src/mini_bridge.rs (new) — the QbzMini singleton. Six properties (open, surface, backgroundBlur, miniQueueJson, miniWidth, miniHeight), boot(), and the two custom-WRITE funnels. `open` mirrors into a static AtomicBool with is_open() beside it, because the hotkeys pipeline runs on another singleton and cannot read this QObject's properties. src/mini_qt.rs (new) — the plain projection module: mini_queue_doc (current track first, then the FULL unfiltered upcoming, no page cap, no art field), the row-to-upcoming-index mapping, the geometry guards, and the pref seeds. Five unit tests, three of them the contract's UT-1/UT-2/UT-3. src/queue_qt.rs — display_title and fmt_duration widened to pub(crate) so mini_qt reuses them instead of growing a third private copy; new publish_mini_queue with its own identical-document guard, published from THE queue funnel beside publish_coverflow. No second queue fetch. src/settings_qt.rs — pref_f32, the reader with a default that the two geometry keys need. Also reattaches three doc lines that sat on read_pref_f32 while describing save_pref, which had none. src/main.rs, build.rs, qml/Main.qml — the registrations: two mod lines (mini_qt is a plain module and is deliberately absent from rust_files), the bridge in rust_files, and QbzMini.boot() after QbzSession.boot(). Two deviations worth naming. Contract row A-9 asks for an invokable set_surface, but row A-3 fixes a property named surface whose generated setter is also set_surface — one QObject cannot carry both, so the funnel sits on the property. QML assigns `QbzMini.surface = n`; there is no setSurface member. And the surface write persists and re-kicks the queue refresh unconditionally, 1:1 with the reference, suppressing only the change notification when nothing moved. cc |
||
|
|
00eaa00027 |
docs(qt): the ambient POC-NOTE in AppShell was a fossil that lied
AmbientField is mounted at :46 and ambientOn derives from QbzShell.ambientMode, so the note claiming the artwork-derived background is unimplemented had been false for some time. Corrected in passing because this port's diff touches the file -- LESSONS-2026-08-03 L10 asks for exactly that rather than propagating it. cc |
||
|
|
28d66352cb |
fix(qt): four defects the independent deferral hunt found
An agent that did not write this code audited it against the contract's §9, §7 and §11.3. Four of its five findings are fixed here (the fifth, the sync() ordering, landed in the previous commit). 1. QBZ_KIOSK_FULLSCREEN was INERT and the log LIED about it. fullscreen_at_boot() had exactly one caller -- choosing a log string -- so an appliance boot with the env set stayed windowed while printing "-> fullscreen-at-boot". The reference does the real thing at qbz/src/main.rs:8611. Now a seeded-at- construction QbzShell.kioskFullscreenBoot drives Main.qml's `visibility` declaratively, so it is part of the FIRST mapped frame. It is one-shot by construction and requires BOTH the profile and the env, which is what keeps a desktop user who toggles kiosk and restarts from being trapped in a shell with no titlebar control (incident 2026-07-11). 2. Shift+I was a reachable DEAD STATE. The rebindable ui.focusMode action reaches the hotkeys dispatcher from the kiosk root like any other non-nav key, so QbzImmersive.open went true over the sanctioned EMPTY mount -- and the kiosk root then rejects every arrow, Enter and Esc while it is true, so the whole zone model froze against a surface that renders nothing. The visualizer tile already declined to open it for this exact reason; the guard now sits in apply_open, the funnel every entry passes through, because the tile was not the only door. It refuses to OPEN, never to close. 3. Kiosk chrome on the small bar was half-ported. The reference HIDES the Lyrics and Queue buttons in kiosk (PlayerBarSmall.slint:846, :983); the port gated only their click handlers, so both still rendered and still toggled side panels the kiosk shell never mounts -- inert affordances, which §9.2 forbids. 4. "Kiosk mode" and "Desktop mode" shipped untranslated. My check_msgids.py compared against the 31 @tr strings in the Slint KIOSK sources, which do not include the two strings this port adds to the view-mode menu -- the checker was right and its input was incomplete. Both now in all 7 translated locales. Verified by driving, in one frame: the small bar's right cluster no longer carries the Lyrics and Queue glyphs, and after Shift+I two Rights still move the shelf cursor (scratchpad/kiosk/f-02-after-shiftI.png). The fullscreen arm is verified only as far as a headless run can go -- both env branches log correctly and the binding resolves -- since actual fullscreen needs a real display, which is Pi/owner-smoke territory. cc |
||
|
|
bf74e77f6b |
fix(qt): the shelf pulse could latch and go dead; re-arm it
Two defects in one seam, both found by an adversarial reviewer reading the diff of the previous commit -- neither has a test that could have caught it, and both are silent. 1. That commit moved set_nav_active BELOW set_hmove. The first arrow key of a session sets both in one model call, so hmoveChanged fired while nav_active was still false and no shelf consumed the pulse. nav_active is CONTEXT a handler reads, not something consumers have a handler on; hmove is the third consumer-facing field, alongside index and activate_seq. The ordering comment undercounted them. Fixed. 2. The deeper one, which predates that commit: hmove is cleared only by a consumer calling takeHmove(). If nobody consumed the last pulse -- no shelf focused, the ring not live, a skipped shelf -- the model still holds it, so the next Right writes 1 over 1 and cxx-qt's generated setter is CHANGE-GATED: it returns without emitting. The key is then dead until the user happens to press the other direction. hmove's value is not its meaning; the edge is. nav_left/nav_right now force the property to 0 first, so there is a real 0 -> +/-1 transition every time regardless of what the consumers did. Verified by effect, not by reading the diff: four consecutive Rights on a Discover shelf move the cursor four cards (scratchpad/kiosk/s-02-four-rights.png). cc |
||
|
|
e5f93d6805 |
fix(qt): the stale-derived-read hazard, swept across the kiosk views
KioskArtist's TypeError was found by luck, not by a method, so an independent agent swept every on<X>Changed handler in the kiosk tree for the same class: a handler reading a property that is DERIVED from the very thing it fired for. Three more, all silent: - KioskDiscover.publishNav() read `navSectionCount` from inside onActiveTabChanged, so it published the PREVIOUS tab's section count (and NaN on the creation pass). publish() clamps `index` into the count it is handed, so a wrong count is a focus ring parked on nothing, not a cosmetic slip. - KioskMyQBZ.publishNav() had the same shape against `cards` and onOnCollectionsChanged, counting the previous route's document. - KioskArtist's shelf skip-cascade read QbzKioskNav.dir inside onIndexChanged. That last one was MY defect, not the view's: sync() wrote `index` before `dir`, and each cxx-qt setter emits its NOTIFY synchronously, so the handler ran while `dir` still held the previous move. The first Down of a session read dir == 0 and refused to skip, parking the ring on a shelf of zero height; a direction reversal skipped back the way it came. Fixed at the source: sync() now writes every CONTEXT field (zone, columns, count, tabs, shelves, dir, hmove, nav_active) before the two fields consumers actually react to (index, activate_seq). The QML keeps its Qt.callLater as well -- that covers Qt's own binding re-evaluation order, which is a different hazard from the setter order. Two independent guards, because this class is invisible to every static check. The general remedy, in all four sites: compute from the source value at call time, never read a cached derived property inside its own handler. Written up at qml/Main.qml:233-247 from the window-geometry work. Gate: 9 warnings = baseline, 291 tests, both audits OK, qmllint clean on every touched file, kiosk-profile smoke clean, and the shelf skip cascade re-driven over VNC (scratchpad/kiosk/s-01-skip-cascade.png). cc |
||
|
|
ea26d743ab |
fix(qt): KioskArtist threw on every mount and skipped the first cover dispatch
Found by DRIVING the app, not by the gate: opening an artist logged "TypeError: Cannot read property 'length' of undefined" at KioskArtist.qml:106 on every mount. The view still rendered, which is why nothing else caught it. collectArtwork() read root.sections -- a readonly property derived from `artist` -- from inside onArtistChanged, which is connected to the very notify that dirties it. The handler runs BEFORE the dependent binding re-evaluates, so the read saw undefined. Main.qml:233-247 already documents this hazard and its remedy from the window-geometry work: compute from the value at call time, never read a cached derived property inside its own handler. Cost of the bug: the first artwork dispatch for an artist page was skipped entirely, so covers only arrived if a later pass happened to re-request them. Verified BY EFFECT -- rebuilt, re-drove to the artist page over VNC, and the run log is now clean -- not by re-reading the diff. cc |
||
|
|
63e36c7543 |
feat(qt): the kiosk shell, its NavRail, the 4 card primitives and the 8 views
Contract sections 4, 5, 6 and the QML half of 7.
ContentRouter.qml is the D3 extraction: ONE view-mount chain, shared by both
shells. The Slint kiosk's content block is a verbatim copy of AppShell's and
KioskShell.slint:10-17 names this extraction as the follow-up it was waiting
for. 23 desktop arms carried over with their comments, 8 kiosk overrides, and
the kiosk-only nowplaying route. AppShell's four viewLoader.item sites now go
through contentRouter.currentItem; a tree-wide grep confirmed no other
reference (LESSONS L4 -- grep, do not trust memory about "both places").
KioskShell.qml carries the back bar with the full search focus lifecycle, the
content-height arithmetic including the now-playing exception, the offline
gate, the forced small transport, the player-zone focus ring, the root key
handler and the 30ms initial focus grab. Two findings worth keeping:
- the NavRail tiles fire header-menu-navigate("favorites-tracks") in the
reference, which switches the view AND selects a landing tab; navigateTo
carries only the route, so the tiles reuse the port's existing pending-tab
handshake rather than landing on the wrong tab.
- the visualizer tile's action is a sanctioned no-op (R2). Wiring
QbzImmersive would be worse than nothing: `open` would go true over an
empty mount and the key guard would then reject every key against a
surface that renders nothing.
Three fractional font sizes had to be rounded. Qt's font.pixelSize is an INT
property, so the reference's 11.5px (NavRail.slint:47) and 12.5px
(KioskNowPlaying.slint:59,67) are not expressible; font.pointSize is the only
real-valued alternative and is DPI-relative, which would make those three
labels scale differently from every other in the tree.
The NavRail one was caught by the offscreen smoke run IN THE KIOSK PROFILE --
it made the whole rail unavailable, i.e. a kiosk with no footer. The build,
both QML audits and qmllint were all green while that was true, and the
desktop smoke was clean because it never mounts KioskShell. The other two
were still latent: KioskNowPlaying is not on the smoke's route.
Gate: build 9 warnings = baseline, 291 tests, both audits OK over 229 QML
files (+15), qmllint filtered = the same 20 pre-existing ArtistView lines and
nothing else, kiosk-profile offscreen smoke clean.
cc
|
||
|
|
221f8704d5 |
feat(qt): kiosk profile — resolution, boot and the live toggle
Port of contract section 8. Three Slint blocks, faithfully:
- main.rs:7855-7876 kiosk_profile_active() / resolved_shell_screen()
- main.rs:8586-8618 kiosk entry (forced reduce-motion; fullscreen ONLY via
QBZ_KIOSK_FULLSCREEN, windowed otherwise -- incident 2026-07-11: a user who
toggles kiosk on the desktop and restarts would be trapped, since the kiosk
shell has no titlebar control and neither Esc nor F11 leave fullscreen)
- main.rs:11767-11780 the live toggle, in the reference's order: persist,
flip the flag, then swap the screen. The flag first means the shell that
mounts next reads a correct kioskProfile on its FIRST frame.
"kiosk" is a SIBLING screen of "shell", not a mode inside it, which is how
app.slint:38 models it (AppScreen { splash, login, shell, kiosk }, sibling
mounts at :145 and :205). Both shells read the same bridges, so the toggle
tears nothing down and playback keeps running.
The toggle reads the CURRENT profile rather than re-resolving: resolving
would let a QBZ_PROFILE env override pin the reading and a user on a kiosk
image could never toggle out (the reference says so at :11764-11765).
ViewModeMenu's delegate called QbzImmersive.openFromMenu() unconditionally,
so flipping any row's `live` would have made it open Immersive. Each row now
carries its own action. Miniplayer stays the one inert row.
The small bar's view button becomes the toggle itself in kiosk
(PlayerBarSmall.slint:746-750): every other flyout row is gated
!kiosk-profile there, so the menu would carry a single row.
reduce_motion's kiosk half is now live -- shell_bridge.rs had the seam
pre-built with a `false` literal and a note saying one line, on their side.
Also: the gear icon. NavRail tile 7 uses settings.svg; this port only had
settings-2.svg, which is two sliders, a different glyph.
288 tests (4 new), 9 warnings = baseline, both QML audits OK.
cc
|
||
|
|
87c20fce8f |
feat(qt): kiosk zone-navigation model (QbzKioskNav)
The Qt counterpart of Slint's KioskNavState global (state.slint:3511-3536) and the five movement functions on the kiosk shell (KioskShell.slint:95-200). The state machine lives in kiosk_nav_qt.rs as a plain Rust model so cargo test can reach it: the contract names the zone model as the piece a lazy port silently degrades, and it is invisible to every static check the gate runs -- no types to resolve, no bridge members to cross-reference, and a click never sets nav_active, so the whole model can be dead while every screenshot looks right. 22 tests cover the transition table. A QML pragma Singleton was not available: the cxx-qt-build generated qmldir cannot mark singletons (theme/QbzTheme.qml:12-13), and an object rooted in the shell cannot be reached by views mounted inside a Loader without a parent-chain walk. Two departures from the Slint original, both deliberate: - columns is clamped to >= 1. Slint's Math.mod(x, 0) is NaN and the comparison quietly fails; Rust's % panics, so a view publishing 0 would take the app down. No behavioural change for any legal value. - Up from row 0 of a view with no tab strip goes to the rail. Slint runs min(tabs - 1, index - tabs), which for tabs == 0 is min(-1, 0) = -1; no item carries index -1, so the ring vanishes until a second Up. Live on KioskAlbum (:46) and KioskArtist (:24), both of which publish tabs = 0. No boot() and no CxxQtThread: nothing in Rust ever publishes into this object, so a hop would be a dead function that looks load-bearing. cc |
||
|
|
085fed81fb |
fix(qt): has_more on local sections counts matching groups only
Self-review catch on the commit before this one. In the album derivation the match filter ran AFTER the total was bumped, so every group the filter rejected still inflated the count and has_more came back true over a destination with nothing extra to show - a "View more" link that lies. The artist derivation already had the two in the right order; the two were inconsistent with each other, which is how it slipped past. Test covers the shape directly: one matching album among six rejected groups, under a cap of 3, must report has_more false. cc |
||
|
|
422e5fd729 |
fix(qt): local sections match the query, and the ranking learns from the page
Three ranking/search defects, two of them found by the owner driving the app.
1. LOCAL sections showed groups that do not match the query.
Searching "Iro" listed Cynic and Die Toten Hosen under "Artists on Local
Library", and their records under "Albums on Local Library". Neither name nor
album title contains "iro". The cause: the SQL matches at TRACK level -
title OR artist OR album - and the album/artist sections are DERIVED by
grouping the matched tracks, so one track-title hit drags its whole album and
its artist into sections that are supposed to be about albums and artists.
The Qobuz sections beside them do not behave that way, so the local block was
lying about what it was showing. Derived rows are now kept only when the
group itself matches: album title or album artist for albums, name for
artists. The TRACKS section is untouched - a track matching on its title is
exactly what that section is for.
The reference has the same flaw; this is a deliberate divergence from it, on
the owner's report. Recorded as such so no future audit "restores parity" by
putting the noise back.
2. The results page taught the ranking nothing.
The port had exactly ONE record site, the cortinilla row click. The reference
has two - the second fires on any media action while the Search view is
current. Without it the case the whole feature exists for is broken:
search "one metallica" -> right track first -> click. Learned.
search "one" -> Qobuz buries it, so it is not in the dropdown ->
"View more" -> click it on the page -> NOTHING is
learned, and the next "one" is just as bad.
record_page_interaction is gated exactly like the reference: Search view
current, module enabled, non-empty query, and the query is the PAGE's, which
is committed by definition. Hooked into the four crate-level media routers,
each self-gated so every other caller is unaffected.
3. Prefix attribution (contract S-N4, owner ruling R8 option ii).
A cortinilla click was learned against the PAYLOAD's query, which lags what
the user has typed: they keep typing while the previous payload is shown,
then click. That teaches a bucket named after a half-typed word - "annen"
learning from a click made while typing "annenmaykantereit". On the owner's
real store, 25 of 143 buckets are typing prefixes like that, and none will
ever be typed again as a whole query, so the score is simply lost.
The promotion is deliberately narrow: only when the payload's query is a
STRICT PREFIX of the live one. Anything else - box cleared, unrelated text,
a narrowing rather than an extension - is attributed as before, because a
broader rule would start moving interactions between unrelated buckets.
DECAY IS NOT IMPLEMENTED and that is the ruling, not an omission: the max
observed score on a real machine is 6 against a cap of 1000, so nothing is
saturating, and decay would add a tunable constant calibrated on nothing
while visibly reordering results.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit S-N4
plus two owner-reported defects.
Gate: build clean, 261/261 tests (3 new pinning the owner's exact "Iro" case).
cc
|
||
|
|
28c04bd990 |
docs(qt): correct the stale search notes
The two whose falsifying commit does not touch their file, so R7 rule 2 could not put them beside the code. QbzEmptyState claimed it had consolidated SearchView's and Cortinilla's no-results states. Neither uses it - both draw their own line inline, and the cortinilla's is a single 28px row inside a dropdown rather than a centred block. SearchView's SOURCE note justified its Qobuz-only assumption by saying the local sections "are not ported". They are now. The assumption still holds for the results PAGE, which is Qobuz-only, so the note is narrowed rather than deleted - the cortinilla is a different document whose rows carry their own source field. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C12 (R7-7, R7-8). cc |
||
|
|
b38b0b3d57 |
feat(qt): instant cached cortinilla paint (default on, ui_prefs opt-out)
The reference shows a skeleton while loading and never paints cached rows
first, and its comments present that as the design. It is not: the instant
paint existed during development and was removed because it GLITCHED -
results visibly jumped between the cached paint and the fresh one. The
owner's reading is that the glitch was a Slint rendering problem rather than
something inherent to stale-while-revalidate, so it comes back here as a
reversible experiment: DEFAULT ON, with a ui_prefs opt-out
(cortinilla_instant_paint) that needs no rebuild to flip. The skeleton path
stays fully implemented as both the opt-out target and the cold-miss path.
What is cached is the FINISHED payload, not the API response. Reviving the
existing CAPA A result cache would have left the expensive half on the table:
a cortinilla payload is the response AFTER ranking, truncation, i18n titles,
the top-result pick and - the part no API cache can shortcut - the local
library query and its derived grouping.
qbz-app gains a generic PayloadCache<T>. The store never inspects T, which is
what lets both frontends share it despite their payload types having diverged
(one raw artwork_url vs an art_url/art_path pair). In-memory only: process
lifetime IS the TTL, because a payload embeds local rows and resolved artwork
paths and a persisted entry would resurrect albums the user has deleted.
Four properties are what should keep it from glitching, and each is a real
constraint rather than a hope:
- Same shape in both paints. The entry is written AFTER the local sections
are appended, so there is no mid-apply reflow left to see.
- Equality-gated replacement. CortinillaData derives PartialEq, so an
unchanged repeat query produces ZERO second paint.
- The selection survives - the publish path never writes selectedIndex.
- The scroll survives - C5's contentY latch. Without it the second paint
yanks the user to the top, which is the glitch by another name.
There are exactly TWO write points, and the placement is load-bearing: after
pass-1 artwork resolution, and again at the pass-2 republish once downloaded
covers land. Writing earlier caches rows with empty art_path, and since the
QML draws art_path only, a hit would paint cover-less rows AND the gate would
fire on every hit - the exact repaint this exists to avoid.
Invalidation: bound per session, dropped on logout, and cleared from
reload_browse - the single chokepoint every local-library mutation passes
through, since there is no library-changed signal to subscribe to.
Also fixes the immersive skeleton's scope bug, which is the twin of the one
C4b fixed on the desktop and which I missed there: a Timer derives from
QtObject, which has no parent property, so the unqualified parent resolved
out of the block entirely - the timer ran forever, pulse never changed, and
the skeleton never pulsed. Found by qmllint, not by reading. It picks up the
reduceMotion tick with the fix.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C11
(section 5.c, rulings R1/R6, R7-10).
Gate: build clean, 258/258 tests + 2 new in qbz-app, both QML audits OK,
qmllint clean on both cortinillas, offscreen smoke 0 complaints + home
published.
cc
|
||
|
|
10f249be24 |
feat(qt): module-OFF results fallback and search service teardown
Two gaps that both made the port quietly lose a capability. Turning Intelligent Search off is a SWAP in the reference, not a removal: typing live-navigates to the full results page on a 300ms debounce instead of opening a dropdown. Here it returned, so with the module off typing did nothing at all until the user pressed Enter - the kill switch removed the feature rather than exchanging it. The per-user search service had no teardown on logout, so the next account inherited the previous one's learned ranking: top_for_query would promote a stranger's most-clicked result and the interaction store would keep accumulating under their buckets. The reference tears it down for exactly this reason. The version counter and both payload snapshots are cleared with it, so a load still in flight at logout cannot publish into the next session. auth_qt's asymmetry note is updated rather than left claiming a gap that is now closed. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C10 (J5, J7, R7-5). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
06e8b4ba4d |
fix(qt): pass the cortinilla row's block padding in instead of reading parent
The row width read parent.leftPadding / parent.rightPadding. It works at runtime - the parent is always a Column that has them - but `parent` is typed QQuickItem, which does not, so qmllint cannot verify it and a wrong mount would fail silently at runtime instead of loudly in the audit. Parent-chain bindings are a documented trap in this port for exactly that reason. The padding is now a property each mount site sets. Cortinilla.qml is qmllint-clean. Found by running qmllint over the files this branch touched, after two identifiers were written before being verified against the source - a Flickable forceLayout() that does not exist, caught by the smoke, and a tracksQuery property that does not exist, caught by reading. Neither the resolution audit nor the singleton xref can see this class of error: they check component types and BRIDGE members, not view-local properties. qmllint does, and it is now part of the gate. cc |
||
|
|
4806584ee2 |
feat(qt): local "View more" routes into the LocalLibrary tabs
The three local sections' View more links now leave the search surface entirely and land on the matching Local Library tab. Sending them to the Qobuz results page instead would show a page with no on-device content at all, which reads as the link having done nothing. local-album -> Albums tab local-artist -> Artists tab local -> Tracks tab, pre-filtered with the live query The query pre-filters the TRACKS tab only. Albums and artists have no search box of their own, so handing them a query would be a lie - the list would come back unfiltered while something claimed otherwise. The active tab is QML-owned state, so the route is a pending property the view consumes, mirroring the localPendingArtist mechanism that already existed for "go to artist" on a local album. It is released after being applied, because the property CHANGE is the trigger and an unreleased route could not fire twice in a row. The consumer also runs on mount, so a route set while the view was unmounted still lands. Setting the tracks filter writes the view's own search state as well as issuing the query, or the search box would render empty next to a filtered list. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C9 (sections 3.4-3.5). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
7e58f2f43c |
feat(qt): cortinilla local row click routing
Local rows route through the local seams, never the Qobuz ones. Their ids
live in a different space entirely: an album id is a group key, a track id is
a library row id, and an artist row has NO id at all. Handing any of them to
the catalog routes would 404 or, worse, open a stranger's album that happens
to share the number.
- album -> the local album view, by group key.
- artist -> the LocalLibrary Artists tab BY NAME, because there is no id to
route by.
- track -> played from the per-query snapshot.
Playing from the snapshot rather than re-resolving the id is deliberate: the
snapshot is the exact vector the payload was built from, so the queue matches
what the user is looking at - including the Plex rows that were prepended,
whose order a fresh lookup would not reproduce. The queue is the WHOLE
fetched vector starting at the clicked row, which is the reference's
behaviour: the dropdown shows three tracks, but activating one gives a real
queue instead of an orphan.
The position lookup is STRICT. If the clicked row is not in the snapshot it
logs and does nothing; falling back to index 0 would silently play a
different track than the one under the cursor, which is worse than not
responding.
Local rows stay out of the learned ranking, which the existing guard already
handled - it was dead code until now because no local rows existed. The
reason is the same id-space problem: a bucket keyed on a local id could never
be matched again, and a local artist has no id to key on.
Playlist rows are no longer inert. The note calling them impossible said
there was no playlist view in the port; open_playlist has existed and
PlaylistView.qml ships, so the row now opens the playlist like every other
surface does. PARITY-DEBT row 28 loses one of its sites.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C8
(sections 3.1-3.3, 3.6, R7-2).
Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
15610bc7e9 |
feat(qt): desktop cortinilla local sections
THE deliverable. The header dropdown returned Qobuz results only; the reference also returns on-device results. It now does too. live() runs the two halves CONCURRENTLY with tokio::join!: the Qobuz search and the local-library query. Neither waits on the other, which is what makes a slow or unreachable Qobuz still produce a usable dropdown. The local half appends three sections LAST - Albums, Artists, Tracks - with Plex unioned in and prepended, and the append re-runs assign_flat_indices so the local rows get contiguous indices after the Qobuz ones. Caps widen offline or on an unauthenticated session, from 3/2/3 to 8/4/8: with no Qobuz half the dropdown IS the local library, so a compact block would waste the panel. The session is read on the synchronous side, before the debounce, so it describes the session at the keystroke. A Qobuz failure now DEGRADES to a local-only payload instead of discarding everything. The old Err arm returned, which is what made the whole dropdown vanish on a network hiccup; the reference's loader has no ? and cannot return Err at all. LAST_CORT_LOCAL keeps the raw rows in lockstep with the payload. The click router cannot re-resolve a track from a row id - a local artist row has no id, and a local album row's id is a group key - so it indexes the snapshot. Both offline early returns are gone. search_live returned before doing anything when offline, justified by a note saying there was nothing to show; that note was the reason this feature stayed invisible, and it is now false. search_submit returned too, which left the PREVIOUS results page on screen, so Enter offline looked like a dropped keystroke. Also lands the Qt half of the cache deletion (S1b). store() persisted into the Intelligent Search result cache, which NOTHING has ever read: SearchService::cached has zero non-test callers in either frontend and git log -S shows the read side was never wired in any commit. Every keystroke paid a full serialize and fs::write of a multi-megabyte artist store, on the calling thread, inside the mutex the UI thread contends for, to feed a file nobody opens. Removing the writer cannot change any rendered output. The Slint half is commit S1a on branch S; this one lands now so the Qt binary stops paying it without waiting for a merge. The immersive dropdown is UNCHANGED and stays deliberately different: albums only, no tracks, no top result, no View more, and selecting a row acts on playback rather than navigating. It shares only the local ALBUM mapper. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C7 (D1-D26, J1, J2, S1b, R7-1, R7-3). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. AC-27: no store in search_qt.rs. cc |
||
|
|
a7e64c0bdf |
feat(qt): search_local.rs - the local cortinilla mappers and loader
search_qt.rs already hosted three controllers and was well past the size
budget, and both dropdowns need these mappers. The immersive arm shipped
first with its own copies of LocalCaps, local_album_artist,
derive_local_album_rows and append_immersive_local_albums; those MOVED here
rather than being duplicated, and the immersive arm now calls the shared
ones. Pure and blocking helpers only - no Qt types, no bridge, no ui().
New, ported from the reference and not yet wired (C7 does that):
- derive_local_artist_rows. Grouped by canonical album-artist,
case-INSENSITIVELY, where the album grouping is case-SENSITIVE. That
asymmetry is the reference's and is reproduced, not smoothed. id is left
EMPTY on purpose: local artists have no id anywhere, so routing is by
name - which is exactly why C5 had to stop hasTop testing id emptiness.
- map_local_track_to_cort_row, with the "artist · album" subtitle and its
fallbacks.
- append_local_sections: Albums, Artists, Tracks appended LAST, section
kinds local-album / local-artist / local so View more routes to the right
LocalLibrary tab, while per-ROW kind stays album/artist/track so the
thumbnail shape and the click router behave. Empty sections are skipped.
- local_art_split, factored out of the album builder so all three row
builders share one artwork rule.
- load_cortinilla_local(query, limit, gated), re-scoped from load_imm_local
with the intelligent-search gate the main cortinilla needs; the immersive
arm passes false because it has its own enable.
Two fixes ride the re-scope. exclude_network_folders was a literal false and
is now the exact twin of the reference - connectivity confirmed Down, not
merely "offline", because a manual offline toggle with the LAN up must keep
NAS content visible. This fixes the cortinilla's call site only; four other
Qt call sites still pass false and PARITY-DEBT #82 stays open. And the path
now carries a [qbz-qt][perf] line: the port had none on any local path,
which is why the cost of this query on a large library was unmeasured.
The module header records the reference behaviours reproduced deliberately:
no dedupe between the Qobuz and local halves, window-relative has_more, Plex
prepended so it can starve the local-file tracks, and no folder-cover
fallback.
Three dead-code warnings are expected and are exactly the C7 seam:
derive_local_artist_rows, map_local_track_to_cort_row, append_local_sections.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C6.
Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
263b82a694 |
fix(qt): cortinilla robustness - scroll latch, hover, hasTop, index guard
Six defects, plus one owner-requested behaviour change.
- The document is now applied imperatively instead of bound. cortinillaJson
is republished wholesale when the artwork pass lands, about 1.5s after
the rows, and a bound Repeater model made that a full model reset: every
delegate destroyed and rebuilt, contentHeight momentarily 0, and
StopAtBounds clamping contentY to 0. The user was yanked back to the top
mid-read. The offset is latched and restored via Qt.callLater - NOT
forceLayout(), which is a QQuickItemView method and does not exist on a
plain Flickable; calling it threw a TypeError and clamped against a stale
contentHeight.
- panelHovered could latch true forever: if the panel went invisible while
the pointer was over it, the hover source never reported false again.
- Hover detection moved from a MouseArea to a HoverHandler. The MouseArea
was declared first, so the Flickable and every row's MouseArea sat on top
of it and took the hover events - containsMouse stayed false across most
of the panel, which is why hovering did not hold the dropdown open. A
plain MouseArea stays behind it purely to swallow background clicks.
- hasTop tested the top row's id for emptiness. Local ARTIST rows carry an
intentionally empty id - routing is by name - so the top-result block
would have vanished for them once the local sections land. It now trusts
the Option the payload already models.
- The row click passed flatIndex raw across the i32 boundary, where
undefined coerces to 0 - the top result's index - so a malformed row
would activate the wrong thing rather than nothing. The highlight already
guarded; the action now does too.
- The nested Repeaters both used an unqualified modelData. It resolves
correctly today, but one added `required` on the inner delegate would
silently flip every row to render its section. Both are named now.
- LAST_CORT's locks no longer unwrap(). row_clicked and move_selection are
invokable bodies, i.e. Qt GUI thread, so a poisoned lock there would take
the UI down over a snapshot that is stale at worst.
OWNER DIVERGENCE (2026-08-03, unplanned): hovering the panel no longer
pauses the idle timer indefinitely - it stretches it to 30s. The reference
pauses outright, which means a cursor left over the panel by accident holds
it open forever. 30s satisfies both halves of the owner's ask: reading is
never interrupted at 4.5s, and a forgotten cursor still lets it go.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C5
(D-BUG-6/9/10/11/12/13).
Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
4e1703c436 |
feat(qt): reduceMotion seam and a coarse skeleton tick
The owner ruled 3-C: build the seam here so the kiosk port consumes it instead of having to invent it. The earlier plan was to wait for kiosk to define it, but kiosk is not implemented - it is a contract - so "wait for kiosk" meant waiting on a date that does not exist. The shape is no longer a guess, which was the only objection to building it early. Two independent sources fix it: the kiosk contract mandates "FORCED reduce-motion while in kiosk", and the reference computes the flag as kiosk_profile || !use_gpu_renderer. QbzShell.reduceMotion is derived, never a preference - do not add a Settings row for it. The existing play-indicator toggle is a preference and a different thing; binding them would make "animate the play indicator" change this dropdown's pulse rate. Only the kiosk half is wired, and the omission is deliberate rather than partial: the renderer tier has no honest source here. It is a QML-side fact, this port runs on the GPU in every real session, and the one place a probe would report software is the offscreen smoke, which forces it by definition. Wiring it would turn reduce-motion on exactly where it means nothing. The kiosk literal is a named const with a pointer to their contract; flipping it is one line on their side. The cortinilla skeleton is the ONE consumer: its single host Timer drops from 700ms to 2000ms. No new timer, no per-delegate animation - the reference's own note records that this pulse was the main cost of opening the cortinilla on weak GPUs. Giving that Column an id also fixes a real scope bug on the way past. The Timer read an unqualified `parent`, and Timer derives from QtObject, which has no parent property - so it resolved up the scope chain, out of the block entirely. The sibling rectangles' parent.parent[.parent] chains are now explicit too. EqualizerBars.qml and the immersive contract's D11 both recorded that Qt had no reduce-motion twin. Both are corrected: the seam exists, those surfaces are simply not consumers yet, and wiring each is its own one-liner. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4b (section 5.g G1-G5, ruling R10; D-BUG-1 folded in). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. AC-39: exactly one behavioural consumer. cc |
||
|
|
033b838b98 |
fix(qt): cortinilla geometry - NPB-aware cap, scrim inset, row width, scroll base
Five geometry defects, all latent while the Qobuz-only panel held at most 14
rows and never scrolled, and all reachable the moment the local sections land.
- The height cap hardcoded 42 for the now-playing bar in every mode. The
bar is 42 only in NPB mode 2; everywhere else it is 112, so the panel
could run up to 70px into it. The cap is now mode-aware, reading the same
theme tokens AppShell uses. The header height literals in the scrims and
the panel anchor became theme.headerHeight at the same time.
- The click-outside scrims left a gap for the search field computed without
the 60px the field gives up when the section nav sits in the header, so
with nav-in-header there was a 30px dead strip on each side of the field
that neither dismissed the panel nor reached the input. The scrim now
tracks HeaderBar's own width rule exactly.
- Rows were 12px wider than their block: a QML Column's padding does not
size its children, and parent.width ignored it. clip hid the overflow,
but the highlight ran flush to the right edge while staying inset 6px on
the left.
- flat_index_content_y started at 6.0, mirroring the panel's 6px top
padding - which is a Flickable VIEWPORT margin, not content space. Every
row was reported 6px low, so scroll-into-view over-scrolled going down
and clipped the row's top going up. The immersive twin, written later
against the same layout, already started at 0.0.
Two unit tests now pin the arithmetic. There were none for the desktop
arm, which is why the 6.0 survived; the immersive arm had its equivalent
and was correct.
- The scrollbar used the primitive's default 14px gutter where the
reference overrides it to 10.
Plus the "Enter" affordance glyph (U+23CE + one space -> U+21B5 + two, as in
the reference) and the panel drop shadow, which reuses the immersive twin's
idiom - a mirror rectangle blurred by MultiEffect, skipped when there are no
shaders - rather than introducing a second shadow mechanism. NavFlyout still
has none and says so; that is its own parity pass.
AppShell's comment claiming the cortinilla is the "LAST child so it renders
above every surface" is corrected: four siblings are declared after it, and
that is fine.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4
(D-BUG-3/4/5/7, rows A5/A7/A10/A12/A13/A19/A28/A2, UT-1).
Gate: build clean, 258/258 tests (2 new), both QML audits OK, offscreen
smoke 0 complaints + home published.
cc
|
||
|
|
5a194d2499 |
fix(qt): move the cortinilla debounce from QML into Rust
The 220ms debounce wrapped the WHOLE searchLive invokable in QML, so live()
- which opens the dropdown and raises the skeleton - only ran 220ms after
the LAST keystroke. Continuous typing therefore showed nothing at all. The
reference opens the panel and raises the skeleton on the FIRST keystroke of
2+ characters and debounces only the LOAD.
live() is now two halves. The synchronous half runs on the first poll of the
spawned task, i.e. at the keystroke: gates, selection reset, open, query,
version bump, loading flag. Then it sleeps 220ms and re-checks the version,
so a superseded task exits without touching the network. This is the shape
the immersive arm already uses (imm_live), which is the precedent in both
respects: raise synchronously, debounce the load.
cortinillaLoading gets exactly one owner in the synchronous half and is
written once more at the publish - never in both halves for the same
keystroke.
Two consequences ride along:
- dismiss() becomes a real cancellation point. It only flipped the open
flag, so an in-flight load survived Esc, published into a closed
dropdown and rewrote LAST_CORT. It now bumps the version too. This
matters more now that the version bumps per keystroke.
- the idle-close timer restarts on the QUERY rather than the payload. A
burst of typing publishes one payload but many queries, and the
reference restarts per keystroke.
HeaderBar.qml's header claimed the cortinilla and live-search were "out of
scope" in the file that wires the whole input path. Corrected here, beside
the code that falsifies it, rather than in a later docs sweep.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C3
(H1/H2/H3/H8, D-BUG-8, R7-4).
Gate: build clean, 256/256 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
dae6c3bb4f |
fix(qt): publish the live cortinilla query synchronously
Enter and "View more" submitted the PREVIOUS query. With the dropdown open
and nothing selected, HeaderBar calls clearSearch() - which wipes the input -
and then cortinillaSearchAll(), passing no text; search_all_action and
view_more then took the query from LAST_CORT, i.e. the last SUCCESSFULLY
LOADED payload. Type "beatl", let it land, keep typing "beatles", press
Enter while the fetch is in flight: the results page loads for "beatl".
The reference is immune because it publishes the query synchronously per
keystroke (SearchState.cortinilla-query, set before its debounce starts) and
reads that. This does the same: a cortinillaQuery property plus a Rust-side
CORT_QUERY mirror so the two async consumers can read it without a Qt round
trip, both written by one function so they cannot diverge - the discipline
CORT_OPEN already documents for cortinillaOpen.
Two more sites move onto the live query with it:
- the overlay's visible gate now requires >= 2 characters, matching the
controller's own gate, so a 1-char query cannot flash a half-empty panel;
- the "No results for X" line quotes the live query instead of the
payload's echo, so the empty state stops naming the previous search
while the user is reading it.
One window stays open and closes in C3: the debounce still lives in QML, so
the query is published when the 220ms timer fires, not on the keystroke
itself. Enter pressed INSIDE that window still carries the previous text.
C3 moves the debounce into Rust, which is what makes the publish truly
per-keystroke.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C2
(D-BUG-2, rows B3/A3/E6/G9/G15).
Gate: build clean, 256/256 tests, both QML audits OK, offscreen smoke
0 complaints + home published.
cc
|
||
|
|
9b6c4d12ac |
chore(qt): delete the dead intelligent-search toggle
The toggle_intelligent_search chain was unreachable end to end: the bridge invokable had zero QML callers, so crate::toggle_intelligent_search and search_qt::toggle_intelligent_search below it could never run. Its comment called it "the app-menu check", but no app menu exposes it in either frontend - the Slint's only surface is AppearanceSettings.slint, and the Qt equivalent reads its checked state from settingsJson and writes through the settings_qt key dispatch, never through this. The intelligentSearch property stays; it is the domain's canonical state. Its comment is corrected to describe the path that actually exists. No behaviour change: the deleted code had no reachable entry point. The build still reports the same nine pre-existing warnings, which is the proof the whole chain went rather than part of it. The torn-read guard the deleted function documented is not lost - settings_qt::toggle_pref_bool still owns it for every other toggle row. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C1 (DEAD-1). Gate: build clean, 256/256 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
fa433d465b |
refactor(qt): extract search_bridge.rs from the QbzBridge god-object
QbzBridge carried five domains (Settings, Search, Playlist, Genre filter,
Discover config); its own boot() comment called the split "TEMP (phase 23)".
TRACK-RULES section 2 requires one cxx-qt bridge per domain. This moves the
Search domain out verbatim, ahead of the cortinilla parity work, so the
refactor and the behaviour change stay separately reviewable.
QbzSearch (search_bridge.rs) takes 7 properties and 11 invokables. Two
deliberate changes ride the move:
- selected_index -> cortinilla_selected_index. It is cortinilla-only
state and the bare name on an app-wide singleton is a collision hazard.
- cortinilla_scroll_y f32 -> f64, unifying with the immersive twin in
immersive_bridge.rs, which was already f64.
cortinilla_query is declared here but not yet wired; C2 wires it, which is
what makes Enter carry the text the user actually typed instead of the last
successfully loaded payload's query.
Every Rust writer of a moving property was re-pointed BY HAND, never by
regex over the multi-line ui() closures: the seven crate::ui( sites in
search_qt.rs plus two that live outside it and that the QML-only xref audit
cannot see - settings_qt.rs (the live Settings > Appearance path) and
main.rs toggle_intelligent_search. All 48 QML consumer sites across
Cortinilla.qml, HeaderBar.qml and SearchView.qml were updated, including the
one Connections block, whose onSelectedIndexChanged handler was renamed with
its property.
A stale 6.0 base in flat_index_content_y is left in place and annotated: it
mirrors a Flickable viewport margin rather than content space, so every row
reports 6px low. C4 corrects it with its unit test; this commit is a move.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C0.
Gate: build clean (no new warnings), 256/256 tests, qml_resolution_audit
214 files, qml_singleton_xref 28 bridges with QbzSearch resolving, offscreen
smoke 0 complaints + home published.
cc
|
||
|
|
07b0ba818e |
feat(qt): keyboard shortcuts cheatsheet and customize editor (B3)
km |
||
|
|
5130e872e7 |
feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km |
||
|
|
0cca9abc58 |
feat(qt): hotkeys core, QbzHotkeys bridge, shortcut i18n catalogs (B1)
km |
||
|
|
c0f4d2f1b7 |
feat(qt): immersive player bar and search cortinilla (B5)
km |
||
|
|
0b6e8ed7c9 |
feat(qt): immersive split view, lyrics, track info, suggestions, queue tabs (B4)
km |
||
|
|
46c1c42408 |
feat(qt): immersive atmosphere and focus panels (B3)
km |
||
|
|
8bc255269d |
feat(qt): immersive root overlay, header band, entry/exit chrome (B2)
Root overlay self-gated on QbzImmersive.open with the load-bearing click-blocker, 6s auto-hide with the VNC hover-storm delta guard, fullscreen guard, exit paths 2-4, seek arrows, the header band (view menu in Slint order, search field on the inert B1 surface, window controls with the first FullScreen write in the Qt frontend), the ViewModeMenu Immersive row live, Shift+I in Main.qml with the text-input gate, QbzLineEdit fieldActive seam (D16). Panels are stubs; B3/B4/B5 replace them. RFB evidence in scratchpad/immersive-b2/. km |
||
|
|
3c721a448e |
fix(qt): openFromMenu must not persist over the remembered view triple
Slint's set_mode(0) fires no view-sig while ImmersiveView is unmounted, and opened() restores the full triple verbatim. Routing the menu path through setView persisted (0,0,0) over the remembered triple on a fresh boot. Round-2 contract finding; 00-CONTRACT.md v3 §3.3. km |
||
|
|
b5298b78bd |
feat(qt): immersive bridge, viz two-source enable, coverflow payload (B1)
km |
||
|
|
ec8588687b |
feat(qt): artist page parity - load more, section sort, discography
The release-section "Load more" fetched a page and appended it to a structure
no view ever re-read. The handler pushed IN PLACE into the object behind
`readonly property var artist: JSON.parse(QbzArtist.artistJson)`, and the
releaseSections projection returned that same array on the unfiltered arm, so
both Repeaters were re-handed the identical reference and nothing was dirty.
The tree's own rule, written in cards/PlaylistCollage.qml: rebind requires a
NEW object reference. A second bug sat on the same path - the artist document
republishes progressively (stories, then each MusicBrainz section as it
resolves) and every republish re-runs the parse, wiping anything appended, so
fixing only the first would have left a bug that looks random. Both
pre-existing; the in-flight placeholder added earlier is what made an
always-silent no-op visible.
Appended pages now live in an overlay keyed by release type. It survives a
republish, emits new arrays so the Repeaters rebuild, dedupes by album id,
carries the reference's 4-page cap per bucket (artist.rs:718-720, which hands
off to the discography page beyond it), and is guarded against a page
requested on one artist landing on another's grid - releaseSectionReady
carries no artist id and every artist has an "album" bucket.
"See discography" was a pointer-cursor MouseArea with no onClicked under a
POC-NOTE saying the page was out of scope. ArtistReleasesView.qml +
artist_releases_qt.rs port ArtistReleasesView.slint: infinite scroll rather
than a button, plus the loading, error-with-retry, empty and offline arms.
The album page's "More from <artist>" view-all is a second door to the same
page. Its paging offset counts what the SERVER returned rather than the
rendered row count the reference uses: blacklist filtering and id dedupe make
the rendered count smaller, so the reference re-requests seen rows and skips
the tail whenever the blacklist is non-empty.
The per-section sort was a dimmed Rectangle with no MouseArea, under a
comment asserting no seam existed. It is now a real QbzSelect with the
reference's five options and index mapping, persisted per release type in
artist_prefs. Choosing "Default" after a sort does not restore the server
order until the page reloads - that is the reference's behaviour
(album_map.rs:262 is a bare no-op arm and resort sorts the live model), kept
deliberately rather than diverged.
Also, two visual fixes from the same round that share these files: Popular
Tracks drew its cover at radius 8 where the shared rows/TrackRow.qml uses 4,
and the album page's rail mounts said `width: parent.width` inside a Column
with 32px padding - QML padding shifts children without narrowing
parent.width, so the rails hung 32px off the right and the right page chevron
landed outside the window.
Carries scripts/qt-diag.sh, which has been sitting untracked since the
rendering round: it answers "which backend is this actually running on" from
a real graphical session, refusing ssh and refusing offscreen because either
would answer the wrong question. That refusal is the point - the doctrine it
disproved ("shader effects render nothing on this port's software path") came
from an offscreen observation, and offscreen forces software by definition.
cc
|
||
|
|
edbc037b0f |
feat(qt): Rust share seam, and fix the dead label share link
The port had no clipboard seam at all. share_qt.rs ports the URL builders and the clipboard from crates/qbz/src/share.rs:1-70. The long-lived OnceLock<Mutex<Option<Clipboard>>> is copied verbatim with its reason: arboard ties the offer's lifetime to the LAST live Clipboard object, so a create-per-copy port passes on KDE, which runs a clipboard manager that accepts the handoff, and silently loses the text everywhere else (#514). arboard carries the wayland-data-control feature at the same pin the reference uses; without it arboard is X11-only and every copy fails on a Wayland-only session. The label header's Share was not merely silent - it copied a link that does not resolve. open.qobuz.com has no /label/ route. The host is per-ENTITY, not per-app: track and album on open.qobuz.com, artist, playlist and label on play.qobuz.com. That asymmetry is why the five builders are ported as a set and why no call site formats its own URL any more: this one did, and got it wrong. QbzHome::label_share now copies play.qobuz.com/label/{id} and raises the "Link copied" toast, 1:1 with main.rs:13164-13178, and the QML Loader/TextEdit clipboard hack it replaced is deleted - its own comment claimed there was no toast seam, which stopped being true when toast_qt.rs landed. Only the URL builders and the clipboard are ported. share.rs:72-184 is the Song.link/Odesli resolution, which applies to tracks and albums; artists and labels are link-only, so porting it now would be dead async HTTP. LabelView also adopts QbzLoadMore here - its two changes share the file. cc |
||
|
|
6f4fb72f57 |
feat(qt): shared QbzLoadMore with an appended-page skeleton
Five hand-rolled "Load more" buttons existed in three visually distinct shapes. One control now carries all three arms, so the idle appearance of every site is unchanged. While a fetch is in flight it draws a QbzSkeleton composite BELOW the button - the owner asked for below - which is one animator for the whole placeholder row, with settleMs 8000 so a fetch that never returns stops shimmering instead of pulsing forever. Idle it costs nothing: the block lives behind an inactive Loader, so mounting the control changes no pixel on a page nobody has clicked. SearchView had half of this already, and the half it had was on the wrong side: a skeleton ABOVE the button, on the albums tab only. All four tabs now use the control, each with its own real cell pitch and row shape rather than the albums tab's numbers copied across. AlbumCollection gains an opt-in tail fade (armTailFade) for the arrival half of the round. It is keyed by the set of ids on screen at click time, not by an index threshold: the label page sorts its grid, and under any order but newest a fetched page interleaves, so a threshold would re-fade albums that never moved - the flicker the fade exists to avoid. It also lets grouped mode fade, where there is no flat index at all. Inert unless a host arms it, so Discover Browse and Play History are bit-for-bit unchanged. cc |
||
|
|
5c2577c7ce |
fix(qt): header atmosphere, NPB button order, small-bar alignment, sidebar collage
The album/artist header band painted the 128x128 atmosphere as ONE Image at anchors.fill + Image.Stretch, mapping the field 1:1 onto the band. That field is vignetted in Rust, so a 1:1 map puts its bright core dead centre and the vignette ring on the edges - the "plasta en medio" the owner reported. ImmersiveAtmosphere.slint:21-67 lays FOUR copies, each larger than the band (+520/+620/+700/+560 wide) and pulled back by a negative offset with image-fit: cover, so what shows is a crop of a magnified field. Ported with the opaque #0a0a0b base and the header's own base/warp opacities. The phase constants are the .slint expressions with tick = 0 (animated: false), signs included: copy 2 subtracts where copy 1 adds, and equalising them stacks the four into one brighter copy. PlayerBar mounted Cast before Connect; PlayerBar.slint has ConnectButton at :401 and cast at :646. NowPlayingBarSmall already matched and is untouched. Seven children of the small bar's right-cluster Row carried no vertical anchor, and a QML Row top-aligns children that do not anchor themselves, so the 32px buttons rendered at y=0 against the 3px seek line with all the slack below. The queue button was among them. The sidebar playlist micro-collage used radius + clip, and clip is a rectangular scissor that ignores radius, so the cover tiles kept hard corners. Rounded with the MultiEffect mask idiom PlaylistCollage.qml documents, on the same _noShaders predicate so a software renderer degrades to square corners rather than a blank tile. Its tile layout also diverged from SidebarPlaylistCollage.slint, which draws ONE full-bleed cover below four covers and only splits into a 2x2 at four, with a 1px seam. cc |
||
|
|
9ce7ef4ca0 |
perf(qt): draw the spectrum in one ShaderEffect per mode (macOS F3)
The 28/48/5 Rectangle delegates cost one dirty node + batch churn each per frame on Qt's Metal path (QRhiMetal::setShaderResources per batch is where the ~6x vs Linux lives). Each mode's Loader now instantiates ONE ShaderEffect — one node, one draw call: heights go to scalar uniforms (b0..bN) pushed in one JS pass per settle tick, bar/gap/ gradient geometry is computed in the fragment shader with the same colors and 1px rounding as the Rectangle version. The pre-existing Repeater delegates are kept verbatim as the fallback arm (software renderer or shader-compile error — the renderer preference added in #104 makes that arm user-reachable). Runtime findings on Qt 6.11 (documented in the file header): inline GLSL strings are dead (fragmentShader is a URL property), so shaders are baked with qsb --glsl 440 --msl 12 and shipped under qml/assets/shaders/ (auto-qrc); and the built-in default vertex stage emits no qt_TexCoord0, so one explicit shared vertex shader is baked alongside. Verified: build + 204/204 tests + both QML audits; RFB (fallback arm, all three modes live during playback); Xvfb/GL (shader arm, all three modes, zero shader errors; QSG_VISUALIZE=batches shows one uniform batch block for the strip). km |
||
|
|
273325f151 |
revert(qt): restore the 16ms settle driver — frequency lever backfires
Measured on the Mac (samples + QSG_RENDER_TIMING, NPB Large, ambient
off): F0 62fps/155ms-s render / ~2.5ms-frame -> F1 51fps/246ms-s /
~4.8ms -> F1b 29fps/~277ms-s / ~9.5ms. Every frequency cut made each
frame MORE expensive faster than the rate dropped — per-frame cost on
Apple Silicon is dominated by wake/scheduling/power-state overhead
(parking the render thread between frames), not by scene work. The
best measured state is the ORIGINAL 16ms driver, so restore it (this
reverts
|
||
|
|
f2be1ccee8 |
perf(qt): stage visualizer publishes, commit them in the tick (macOS F1b)
QSG_RENDER_TIMING on the F1 build (Mac, NPB Large, ambient off, 3.5 min of playback) showed the F1 throttle only bought 62 -> ~51 fps, not 30: the producer publishes at ~30 Hz AND each publish wrote from/to/ progress directly — at()'s only dependencies — so every 33 ms period invalidated the window TWICE (publish + tick). ~51 fps x ~3.6 ms render kept the render thread at ~20-25% of a core. Stage the publish into pendFrom/pendTo (which nothing visual binds to) and commit + advance in the driver tick: ONE visual write per period. Pixel-identical output (the bars hold the settled old frame until the tick, exactly what progress=0 showed), ~30 fps instead of ~51. Audits green (resolution + singleton xref), build green. km |
||
|
|
447efe2cd7 |
feat(qt): consume the renderer preference at startup (#104)
The Settings > Appearance RENDERER row rendered and persisted into the SAME ui_prefs the Slint reads (settings_qt.rs:2105-2110) but nothing consumed it — pick "software", restart, and Qt stayed on its default: the row lied. apply_renderer_preference() now runs before QGuiApplication::new with the Slint engine's precedence (main.rs:7825-7848): explicit Qt envs (QSG_RHI_BACKEND / QT_QUICK_BACKEND) > QBZ_RENDERER > persisted pref > Qt default (Metal on macOS, OpenGL on Linux — both measured healthy). Cross-frontend mapping: the GPU tiers (auto/wgpu/gpu/hardware/hw) ARE Qt's default on both platforms; gl -> QSG_RHI_BACKEND=opengl on Linux and the Metal default on macOS (the Slint remaps GL to skia(Metal) there, main.rs:7651-7653); software -> QT_QUICK_BACKEND=software. Still owed and kept in PARITY-DEBT #104: the crash auto-revert sentinel and the frame-liveness watchdog — forcing a renderer without them can leave a broken startup. Gate: build + 204/204 tests + both QML audits green. km |
||
|
|
9db2176d2e |
perf(qt): throttle the visualizer settle driver to 33 ms (macOS F1)
The 2026-08-02 macOS measurement (2026-08-02-macos-perf-fix-plan.md) named the actual burn: not the backend (real Metal, DPR 1, GPU at 50-57%) but per-tick invalidation. The VizSettle driver at 16 ms invalidated the scene ~62x/s; Qt Quick has no partial damage, so each tick re-rendered the whole 3440x1440 window (~2 Metal render passes), ~6x pricier per frame on the Metal path than on Linux/Mesa. Paused: 0%. The producer publishes at TARGET_FPS = 30 and the Rust EMA band-limits the signal to ~6 Hz, so 33 ms keeps one interpolated step per published frame — the render bill halves and the motion difference is at the edge of perception. Self-parking, the live gate and the Rust side are untouched. Expected on the Mac: ~30% -> ~15-18% CPU at NPB Large playing. km |
||
|
|
7c9e4f1878 |
fix(qt): type local/plex playlist rows by provenance, not as qobuz
row_to_queue hardcoded source "qobuz" for every row, so a local/Plex row in a local playlist's detail — adopted into the shared page with its library rowid (or 2^40 synthetic Plex id) — was re-typed as a catalog track and passed every Qobuz Connect admission guard (id > 0 && source != local/plex). A row ⋯-menu enqueue with a peer active pushed the rowid to the WebSocket as a Qobuz catalog id and the peer renderer played an UNRELATED track; with no peer the bogus id was synced and resolved to the wrong track locally. That is the track-source-admission P0 class from the QConnect hardening review. Make row_to_queue provenance-aware (the row already carries row.source). Typed correctly, the existing guards do their job: peer active -> refusal toast, added nowhere (Slint parity); no peer -> lands local, correctly typed, sync-on-add skips it. Unavailable rows (raw path / kind:ref ids) parse to 0 and fail the id > 0 guard, so they can never reach the WS either. One choke point, which also closes the latent mistype for the day the online-playlist sidecar merge is ported (PARITY-DEBT #103). Verified: build + 204/204 tests. Owner smoke: local row enqueue with QConnect on a peer toasts "Track not castable to Qobuz Connect" and adds nothing. km |
||
|
|
8776ebe82d |
fix(qt): carry the real track data into every queue, and shuffle for real
Two defect classes the owner found by playing music, both of them the port diverging from a reference that gets it right. QUEUE TRACK FIDELITY. The reference has ONE builder, make_queue_track (playback.rs:2426), and it takes the raw Track, so bit_depth and sample_rate come straight off the API model. This port forked that into ten builders and five of them mapped from a DISPLAY ROW instead — a row that carries qualityTier / qualityDetail / qualityLabel, the already-formatted strings, and no raw numbers. Those five hardcoded bit_depth: None, sample_rate: None: playlist_qt, artist_qt, label_qt, library_qt and playback_qt's feed_queue_track. The consequence reached the user as a broken NPB AudioStamp: the track edge seeds quality_state from those same numbers, so starting playback from a playlist, an artist page, a label page or a Library-feed row left TRACK_MAX at zero and the badge could not render a complete bit-depth / sample-rate line. Starting from an album was perfect, which is what made it look intermittent — the album path was one of the five that had always built from the raw Track. The rows now carry the numbers alongside the strings, because the producer derives the strings FROM those numbers and was discarding them; parsing "24-bit / 96 kHz" back into integers would have been a second source of truth. The same builders were dropping artwork_url, which is why the queue sidebar had no covers for playlist-sourced or local rows. Also fixed here, found while tracing: local_playlist_qt::play shipped context: None, so set_queue_stamped fell through to derive_context, which answers None for a many-album playlist. The song card then used its per-track album fallback and pointed at a local album_group_key instead of the playlist. Pre-existing for the header Play, but a guard added last round routed the row click through the same path, which turned it into a regression there. SHUFFLE. set_shuffle(true) only randomises what comes NEXT, so four entry points raised the mode and then played the list's #1 track: the album header, the playlist header, the local-playlist header and every local play_rows call. The one track the user actually hears on pressing Shuffle was deterministic. All four now mix their own list with the xorshift Fisher-Yates the port already uses in play_track_list_in and start at the top of the mixed order. The mode stays raised so playback continues shuffled past the list. The owner traced the flag-and-play-from-the-top shape to a very early Tauri experiment; it is not the behaviour anywhere. Not addressed, going to a separate pass: a Qobuz playlist carrying local or Plex sidecar rows. The reference routes those through the local snapshot (playback.rs:3488 is_mixed); this port has no sidecar concept on playlist_qt at all, so such a row is still typed as qobuz. Same defect class, one layer up. cc |
||
|
|
c8559a68b3 |
feat(qt): port Qobuz Connect — service, flyout, routing and touchpoints
Full-parity port of the Slint QConnect integration per the contract at qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three adversarial review rounds on the spec, §12 trap sign-off PASS on both halves, RFB-verified incl. a real cloud connect, live session owner-tested). The shared crates are consumed verbatim — zero backend changes. - adapter quartet: qconnect_transport_qt (credentials, identity on the shared qconnect_settings.db), qconnect_engine_qt (start_track_stream fallback chain + feeder helpers + CMAF cursor sync), qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect recipe, controller routing, echo guards, offline parking B12, startup auto-connect) - QbzQConnect bridge singleton + on_session_entered wiring; one shared flyout for both bars (tooltip on the large bar only), Developer > Connect diagnostics modal, device-row glyphs, settings rows live - poll loop: peer-reflection branch before cast, ~2 s renderer reports, sync-on-add on every Qobuz-castable add path - routing: cast → QConnect → local (mute QConnect-first), play arms after the set_queue funnel, reorder cloud arm with no local fallback, volume/mute/seek remote-first - touchpoints: lyrics remote anchor, scrobble guarded / Discord unguarded, cast suspend/resume, volume-lock lifted when remote km |
||
|
|
35508b5f87 |
fix(qt): the defects found smoking the playlist and My QBZ round
Un-following a Qobuz playlist left it in the sidebar and in My Library. toggle_follow called unsubscribe_playlist and settled the ownership snapshot and stopped there, so the row only left at the next full refetch. Both surfaces are patched directly now, in both directions: following from a card buys the metadata with one get_playlist rather than re-reading getUserPlaylists, which lags the write. The un-follow collapses a hearted-and-followed playlist to the one row the next load will actually produce instead of dropping it, because the local heart is a library.db flag that survives the unsubscribe — removing every row made it vanish and come back, which is the original complaint wearing the other shoe. The Plex mark on album cards was a hard-drive glyph tinted accent — a blue hard drive — and the three Qobuz kinds drew no badge at all. The badge now ports discover/AlbumCard.slint:326-354 exactly: 24x24 at radius 4 pinned bottom-right with a 6px inset, plex-logo untinted at 16, qobuz-logo at 18 (22 for a download), the purchase arm gold on its amber chip, hard-drive white at 14 for local only. The brand marks are plain Images: routing them through QbzIcon floods every pixel with the tint and flattens a logo to a silhouette. SourceIcon moves to controls/ since it is no longer local-only, and every other surface drawing a hard-drive was audited against its .slint. The local album detail loses the two nav chevrons above the art. They are a Tauri-era leftover from before the app had global navigation. My QBZ used a bespoke card in the grid. MyQbzDetailCard is deleted and the standard cards/AlbumCard is mounted with its overlay. localMode bundled four separate things — action routing, the heart, the pin and Block this album — so a multi-source container had to switch all four off together; it is split, and the catalog affordances now follow the ITEM. On a Qobuz cell the heart works through the shared favourite seam; on a track, playlist, local or Plex cell it is absent rather than dead. Row covers carry two rungs: the list keeps _50, the grid gets _600. Not the _230 that would merely cover a 200px card — the complaint was that the cover reads softer than a normal AlbumCard, and a normal AlbumCard is fed image.large, which IS _600. _200 is not a rung the CDN serves at all (it answers 404). The My QBZ grid sets reuseItems, and AlbumCard assigns to its own isFavorite and isPinned when toggled, which destroys the binding that fed them. A recycled delegate therefore showed the previous row's heart and the click added the new album while the glyph said remove. Restored with the Qt.binding idiom this port already uses on TrackCard, PlaylistCard and TrackRow. The details view no longer force-opens every row on entry, and the per-row open state persists. Library > Artists showed placeholders for nearly every portrait. The side panel never reported its artwork window — the one caller of libraryArtworkWindow is LibraryView.reportWindow, and the panel had no report call at all, so no key ever reached the resolver and artMap stayed empty for its rows. The rail now reports through that same path, with the section-header sentinel reportWindow already skips. Note the reference has no portrait fallback either: an artist Qobuz serves no image for is a placeholder on both sides. cc |
||
|
|
a16cf56c86 |
feat(qt): playlist manager, folders, importer and the Library parity round
Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc |
||
|
|
ac9740b83a |
assets(qt): add the glyphs and brand marks the playlist block needs
63 tinted icon variants and 3 brand marks, split out of the feature commit so the code diff stays readable. The icons cover the Playlist Manager (grip-vertical for the grid reorder handle, chart-no-axes-column for the play-count badge, folder-plus, library-big, refresh-cw), the folder editor's preset picker, and the queue's circle-stop marker. Each ships in every tint directory the baker consumes; a glyph present in only some of them renders nothing in the others, because icon_tint_qt fingerprints the icon SET and a partial set bakes a partial cache. The brand marks are the importer's allowed-source row. cc |
||
|
|
0bfe297776 |
fix(qt): gapless keeps its state across every backend change
Owner, 2026-07-31: "hay que mantener el status activo, sin importar el cambio en el backend." Gapless now survives a backend switch, ALSA included. A deliberate divergence from BOTH references. Tauri forced it off when moving to ALSA (`SettingsView.svelte:3409-3416`, "not compatible with ALSA Direct") and Slint copied that (`settings.rs:1232-1237`); the two agree despite the different wording, because in the engine's own vocabulary `using_alsa_direct` IS `backend_type == Alsa` (qbz-player/src/player/mod.rs:725-729). The exclusion looks broader than the hardware demands: `PlayNext` is backend-AGNOSTIC — it appends to the live engine and refuses only on a missing engine, a sample-rate/channel mismatch, or an active streaming source (mod.rs:3553-3586) — and ALSA Direct does have an engine with its own writer thread. Within one album at a constant rate there is no mechanism here that should break. Nothing in qbz-audio or qbz-player is touched; this only stops the frontend from forcing the user's setting off. What this port had was worse than either reference: the guard was missing entirely, so EVERY backend change killed gapless — including switching to PipeWire, where it works perfectly. That is what read as "alsa breaks gapless": the switch itself turned it off, and re-enabling it was what fixed it, not the restart it happened to coincide with. The `streaming-only` cascade still disables gapless and is untouched — that one is about there being no cached file to hand the engine, not about the backend. NEEDS REAL-HARDWARE VERIFICATION on the owner's DAC: the failure mode to watch is a transition that renegotiates the stream mid-album (a rate change), which the format-match guard should already refuse. cc |
||
|
|
c8e4f415e7 |
fix(qt): a backend change only disables gapless when moving TO Alsa
The guard was missing, so EVERY backend change silently turned gapless off — including switching to PipeWire, where it works perfectly. A user who did not notice the toggle flip reads that as "alsa breaks gapless", which is exactly how it was reported. The reference is conditional: `settings.rs:1232-1237` disables it only for `backend == AudioBackendType::Alsa`, and that in turn matches Tauri (`SettingsView.svelte:3409-3416`, "not compatible with ALSA Direct"). Those two are faithful to each other despite the different wording: in the engine's own vocabulary `using_alsa_direct` IS `backend_type == Alsa` (qbz-player/src/player/mod.rs:725-729). Only the Qt port dropped the `if`. Investigated and RULED OUT while tracing this, recorded so it is not re-derived: the audio thread's `thread_settings` and the Player's `audio_settings` are the SAME `Arc<Mutex<AudioSettings>>` (created at mod.rs:1370, cloned into the thread at :1371, stored on Self at :4067). `reload_settings` therefore does reach the running audio thread, and no restart is needed for a settings change to land. The apparent "restart makes gapless stick" is this cascade: the switch persisted `gapless_enabled = false`, and turning it back on is what fixed it — the restart was just when that happened. Whether gapless SHOULD work on Alsa at all is a separate, deliberate rule inherited from Tauri and left untouched here: the engine's `PlayNext` handler is backend-agnostic and refuses only on a missing engine, a sample-rate/channel mismatch or an active streaming source (mod.rs:3553-3586), so the blanket exclusion may well be broader than the hardware demands. That is a product call on the PROTECTED bit-perfect path and needs the owner's word plus real testing on the DAC. cc |
||
|
|
8f416704a8 |
fix(audio): verify the forced rate against the key that was forced
Owner-authorised change inside the protected backend, and it touches no audio:
the fix is entirely in what the log CLAIMS.
`get_pipewire_current_rate` wrote `clock.force-rate` and then read `clock.rate`
to check it. Those are different things in PipeWire — `force-rate` drives the
graph, `clock.rate` is the graph's NOMINAL default and does not move when a rate
is forced. So the check compared its request against a key it had never touched,
got the untouched 48000 default, and logged "Audio may play at wrong speed" over
perfectly correct output. The retry never helped because the delay was never the
problem.
Measured directly on the owner's box with the app closed:
clock.rate = 48000 <- what the check read
clock.force-rate = 96000 <- what the code wrote, and what the DAC showed
The owner had been living with the warning precisely because the hardware
disagreed with it: "el dac muestra los 96 ... por eso no me he clavado".
Now: read `force-rate`, fall back to `clock.rate` when nothing is forced (it
reads 0 then), which is the honest answer in that case. The parse is split into
`read_pw_setting(key)` so the two keys cannot drift apart, and it matches
`key:'<name>'` EXACTLY — `clock.rate` is a substring of `clock.force-rate`, so a
`contains` would cheerfully report the forced rate when asked for the nominal
one.
Worth fixing even though the audio was always right: in a bit-perfect player a
warning that cries "wrong speed" over correct output trains you to ignore it,
and buries the day it is true.
Not addressed, observed while measuring: `clock.force-rate` stays at 96000 after
the app exits, so the system graph is left pinned until something else changes
it. May well be deliberate; flagged, not touched.
cc
|
||
|
|
d1862e90da |
fix(qt): the Source column draws the real Qobuz and Plex marks
Owner: "si es de qobuz, icono de qobuz, si es de local (folder o plex) cada uno tiene sus svg; Slint y Tauri los tenían bien." They did. The port did not, and the reason was an asset gap rather than a styling choice: `qobuz-logo-filled.svg` and `plex-logo.svg` were never carried over from the Slint tree, so `SourceIcon` substituted `cloud-download` for every Qobuz kind and a tinted `hard-drive` for Plex — drawing "downloaded" and "local file" where the design calls for two brand marks. Both assets now live in `qml/assets/brand/`, beside the Discogs / Last.fm / MusicBrainz marks that are there for exactly the same reason. The rule this restores is one `SourceGlyph.slint:4-8` calls load-bearing: the full-colour logos render UNTINTED and only the monochrome local glyph is tinted, to MUTED text and never accent. Slint spells out why — routing a logo through a tinting primitive "would turn the logos into a flat accent silhouette, the bug this primitive replaces". `QbzIcon` floods every pixel with its tint, so the two logo arms deliberately do not go through it. `qobuz_purchase` is the one arm that must tint a multi-colour asset (the same mark in #eab308 — the colour is what says "purchased"), so it is the one arm that uses an effect, and it degrades on a software renderer to the untinted mark rather than to nothing. The two ROW call sites now pass SourceGlyph's own numbers (15px, muted) instead of inheriting the version picker's (14px, secondary) — the two Slint components this file stands in for disagree on both, which is why they are properties. Verified by driving the app: the Qobuz rows show the Qobuz mark and the Plex rows the gold Plex chevron, in their own colours, with zero "Cannot open" in the log. cc |
||
|
|
83c1aec4f5 |
feat(qt): collapsible rows in the My QBZ detail, and ephemeral leaves no trace
**The accordion the owner asked for.** Each expandable row gets a chevron; clicking it opens THAT row's tracks inline and nothing else. Verified by driving the real app: in list mode every row is collapsed with the chevron pointing right, clicking row 3 — a PLEX row, so the hybrid path — flips its chevron, renders its three tracks with durations and quality badges, and leaves rows 1, 2, 4 and 5 untouched. This is a DELIBERATE divergence from both references, decided by the owner with the facts in hand: neither Slint nor Tauri has a per-row accordion. Both render inline tracks only as a view MODE, for every row at once, and both say "no chevron" in their own comments (MixtapeDetailView.slint:1139, MixtapeCollectionDetailView.svelte:124-127). The owner first believed Tauri had it, asked for the code to be checked rather than the comment, and on the evidence chose the accordion anyway. Do not "restore parity" by removing it. Built additively on machinery that already existed: the document already carried `canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`. What was added is per-row OPEN state in Rust, a toggle invokable, and lazy per-row fetching — opening a row fetches only its tracks, once. The "expanded" view mode survives and now means "open all", sharing ONE notion of open rather than a second parallel state. Also here: - `body`'s `anchors.bottomMargin: 100` shrank the list VIEWPORT; in the .slint that 100px is padding INSIDE the Flickable, i.e. scroll runway. Measured on screen before the fix: the last row clipped mid-height with ~85px of dead band under it. - `apply_resolved` cloned and serialised the WHOLE document per resolved item (34 serialize→parse→patch cycles for one collection, 200 for a big one, on the GUI thread), and `patch_expanded` did the same while the document carried every loaded item's inline tracks — O(n²). - The DEAD affordances the audit filed against the modals and the grids. **Ephemeral honours its name** (owner rule): an ephemeral folder plays and queues, and writes nothing durable. Recently Played and Most Played are now guarded — an ephemeral album played through would previously have outranked real albums in Most Played, one row per track start. The Slint reference does NOT have this exemption; that is a gap in both frontends, not a Qt regression, and this does not restore parity by removing it. Two stores were deliberately NOT changed and need the owner's word: the scrobblers and Discord presence. Neither is "las bibliotecas" — nothing is stored on our side — but both publish the track to a third party, so if "ni now playing ni nada" is meant literally they belong in the same decision. The one-line fix location is in the lane report. cc |
||
|
|
b6cf06ff2a |
feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db opens instead of 23-34 — the local source owns a pooled handle and hands out no guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an await either. Bug 1 from the seam design is dead by construction, not by care. The owner's rules, decided today, now live in one place instead of eight: - **Collection** takes albums, singles and EPs from ANY source — qobuz, folders, Plex, and whatever is added next. **Mixtape** takes albums, playlists and tracks. This was a single boolean (`restrict_to_mixtape`) that could express neither release types nor "collections accept local". - An unsupported combination must never be VISIBLE: the picker stops offering the impossible action, and the error stays a log-only safety net. Owner: "el bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI". - **Ephemeral honours its name** — playable, queueable, and offerable to nothing else. - A row's source comes from the ROW. Eight QML sites built the add-to-mixtape payload with `"source": "qobuz"` written as a literal, so a Plex or local track was stored as Qobuz and afterwards could not play at all: the app asked the Qobuz API for an id that never existed there. `QobuzSource` reads the client through a lens on every call instead of holding a clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384, both on paths that run before `set_session`), so a cached clone goes stale and fails silently — the hazard the crate's own doc comment described. The lens is installed in `on_boot`, first of everything, and finding out why is the lesson of this commit. The gate was green — build, 142 tests, both QML audits, a clean offscreen smoke — and the feature was visibly broken: every Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE" type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever touches it first BUILDS it, and one built without the lens leaves Qobuz permanently detached. Only driving the real app surfaced it; the crate's error string named the cause exactly, which is the one thing that made it a ten-minute fix instead of an afternoon. cc |
||
|
|
f1b4241342 |
feat(source): add qbz-source, the source-agnostic media seam
Stage 1 of the seam the owner asked for: "son vistas súper híbridas donde se mezcla Qobuz/Local(Folders/Plex) ... hay que hacer una solución agnóstica porque no tardamos en meter a Jellyfin y quizás Navidrome". Nothing is wired yet — this lands the crate, its three source implementations and its tests. `qbz-qt` and `qbz` are untouched, so this commit changes no behaviour. The problem it exists to end, measured rather than assumed: 129 source-branching sites across 30 files in qbz-qt alone, SEVEN distinct item->tracks resolvers none of which can reach the others, FIVE non-interchangeable artwork pipelines over one correct taxonomy, and six incompatible source vocabularies — three of which cannot express Plex at all, including the PERSISTED one (`AlbumSource` is `Qobuz|Local`). Every hybrid view re-wires the per-source handling by hand, which is why something is always missing. One `Source` trait covering the four concerns together — tracks, metadata and origin, artwork, playback — because splitting them is exactly what lets a view forget one. A `SourceRegistry` with claim-priority ordering (Qobuz, Plex, then Local; Plex-before-Local is load-bearing). `RegistryResolver` implements `qbz_mixtape::ItemResolver`, so that crate and the Slint glue stay byte- untouched and keep compiling. The three measured bugs die structurally, not by discipline: - The local DB was reopened PER CALL, so a My QBZ collection logged one "Opening library database" per item (34 measured live). `LocalSource` owns a pooled handle, and it hands out no guard — only `with(|db| ...)` — so a `!Send` `&LibraryDatabase` provably cannot cross an await. `qbz-mixtape`'s sync-resolver constraint becomes an API property instead of a comment. - A `plex:<hash>` album key reached an API that wants a rating key. `MediaRef` has no public constructor: only `registry.claim` mints one, and `PlexSource::claim` rejects that shape in track position. The survey also found a second, undiscovered face of the same bug — "Go to album" on a Plex track row opens an empty page. - Local rows in a hybrid list rendered placeholder discs. Traced to its real mechanism: those items are stored with `artwork_url: None` and the only backfill is behind a Qobuz-client gate, so offline or pre-login the art is on disk and never shown. `Source::artwork` is one call per row whatever the source, and keeps the cheap/cold split the local path already had. Adding Jellyfin is one new file, four diff lines inside this crate, and one line in `auth_qt.rs` — and the design states this only holds BECAUSE artwork sits on the trait. 36 tests. The id tests derive their fixtures from `PLEX_TRACK_ID_FLOOR` instead of transcribing them, which is not pedantry: the survey's own id table — the artefact meant to be the definitive reference — paired a namespaced id with the wrong rating key, and the transcribed test caught it on the first run. Design and adversarial review (6 BLOCKER / 10 MAJOR / 7 MINOR, all corrected): qbz-nix-docs/qt-frontend/2026-07-30-source-seam/. cc |
||
|
|
ea373a601c |
fix(qt): four AUDIBLE parity rows — stream errors, seek clamp, label shuffle, Plex keys, Library queue
PARITY-DEBT #7, #15, #17, #4 and #5. - **#7** the stream error was drained and only logged, so an ALSA / exclusive failure — guaranteed inside Flatpak and Snap — froze the bar at 0:00 with no explanation. It now toasts, with the sandbox-aware rewrite the reference carries (playback.rs:5093-5115). This is the support issue that fix was written for. - **#15** seek was clamped to the full duration instead of the downloaded fraction, so the head could be dragged into bytes that do not exist. The poll loop publishes `npSeekableMax` and both bars clamp to it, matching SeekBar.slint:93-98. The cache overlay was already drawn and purely decorative. - **#17** a label page's Shuffle called `set_shuffle(true)`, turning the GLOBAL shuffle mode on and leaving it on for everything played afterwards. The reference shuffles the vector in place and never touches the mode. - **#4** a `plex:<hash>` album-boundary key was passed where a Plex rating key belongs, so the fetch 404'd and the PREVIOUS track kept playing under the new card. Guarded at both sites, 1:1 with playback.rs:668-680 (`b5c1a76e`). - **#5** clicking a track in Library played exactly one track and stopped. It now queues the whole VISIBLE list from the clicked row (`order_by_visible`). The Library row play needed one edit outside its author's lane, which the singleton xref caught before the build did: `QbzLibrary.libraryPlayVisible` was called from QML and implemented in `library_qt`, but never declared on the bridge. That is precisely the failure the xref exists for — QML resolves singleton members lazily, so a missing invokable compiles clean and throws when the user clicks. The QML had shipped a `typeof === "function"` feature-detect with a fallback, so it degraded instead of throwing; the invokable now exists and the fallback is dead code by design. cc |
||
|
|
1c2bdb749a |
fix(qt): the My QBZ detail delegates read the live row, not the model's snapshot
The whole in-place patch path was a silent no-op, and it took the Quality column and expanded mode down with it. `model: root.rows` is a JavaScript array. QQmlDelegateModel SNAPSHOTS such a model, so a delegate's `modelData` is the engine's own copy of the element, not the object sitting in `root.rows`. `syncRows` patches with `Object.assign` on purpose — reassigning the array resets contentY and throws the user to the top of a 200-item collection — and then bumps `patchRev` to re-evaluate the bindings. Every part of that worked except the last one: the bindings faithfully re-read the STALE copy. Measured before the fix, driving the real app over VNC: 34 items resolved Rust-side with correct tiers (26 get_album, all 200 OK), 34 publishes, 71 QML refreshes, `syncRows` taking the patch branch every time — and the Quality column sat on its loading dots forever, byte-identical five minutes apart. Expanded mode rendered no inline tracks at all, for exactly the same reason: `inlineTracks` is delivered by that same patch. The fix is to index the live array: `patchRev` stays the dependency and `root.rows[index]` is the object Rust actually patched. `modelData` remains the fallback for the frame where the array and the delegate model are momentarily out of step. No array reassignment, so the scroll position is still preserved. After: the badges resolve to "CD 16-bit / 44.1 kHz" and the inline track lists render with their durations and per-track badges. cc |
||
|
|
b8b493bd59 |
fix(qt): persist the player volume, so a launch stops starting at 100%
PARITY-DEBT #3 — the cheapest AUDIBLE row in the inventory. Volume was never written and never restored: `setVolume` reached the engine and stopped there. Set it to 15%, quit, relaunch, and playback came back at full scale. On an exclusive-mode DAC at night that is not a cosmetic default. 1:1 with the Slint: persist on drag-END only (`NowPlayingState.persist-volume`, PlayerBar.slint:864-866 -> qbz/src/main.rs:14413-14417), restore at shell entry (qbz/src/main.rs:220-227), and let the poll loop mirror the engine back onto the slider so nothing else has to publish it. Drag-end matters here rather than being a nicety: ui_prefs.json is co-owned with the running Slint app and both processes do a whole-document read-modify-write, so a write per drag tick is a write per pixel into a shared file. `QbzSlider` only had `changed`, which fires on every tick, so it gains a `released` signal carrying the settled value — both now-playing bars use it. Two supporting bits: `QbzPlayer.persistVolume` (the engine setter stays per-tick, only the persist is debounced by the signal), and `settings_qt::read_pref_f32` — the crate had no public ui_prefs reader, theme_qt kept a private one, and a third private copy is exactly what the write discipline at the top of settings_qt exists to prevent. Verified the key name against the live file: `volume` is already present in the shared ui_prefs.json alongside 60 other keys, so this reads and writes what the Slint build reads and writes. cc |
||
|
|
424c2ea552 |
fix(qt): the blacklist reaches the queue, and one dead track no longer wedges it
PARITY-DEBT #1 and #2, the two worst AUDIBLE rows in the inventory. **#1 — a blocked artist still played.** The store was open (auth_qt binds it in all three session paths), the Blacklist Manager worked, and `search_qt` filtered against it — but `playback_qt` had zero blacklist references, so a blocked artist entered the queue from an album, an artist's top tracks, a label "play all", a radio or a compilation. The one row where the user has explicitly said "never play this". The Slint applies the filter at ~18 separate builder sites (`filter_blacklisted_queue`, playback.rs:2624-2641). This port funnels every play path through `set_queue_stamped` and every enqueue through `stamped`, so it needs exactly two — which is also why the gap was total rather than partial: one choke point that nobody taught. The start index is the part the reference gets for free by filtering before it computes the index. Here the caller has already picked an index into the UNFILTERED list, so dropping rows ahead of it would silently start the wrong track. `filter_queue_with` remaps it, and the arithmetic is split out as a pure function with five tests (clicked row survives / clicked row was blocked / the whole tail blocked clamps to the last survivor rather than past the end / everything blocked yields no index / nothing blocked is identity). **#2 — one unavailable track stopped everything.** `play_queue_track` logged the error and returned, so a region-locked or missing track ended the session, and the early return also skipped both refreshes: the bar kept showing the PREVIOUS track over a silent player. Tauri #467, re-introduced. Ported from playback.rs:766-818: classify the error, then walk. A 403 or the #637 client-side back-off is NOT the track's fault (every track 403s the same way) so it stops cleanly with its own message instead of burning five good tracks looking for one that works. A transient failure is left alone — the client already retried it. Only a TERMINAL unavailability skips, bounded at 5 consecutive, reset the moment any track actually starts. The recursion returns a boxed `dyn Future + Send` for the reason the reference documents: an inferred `impl Future` makes Send-ness self-referential and the compiler hits a query cycle. Nothing in qbz-audio or qbz-player is touched. cc |
||
|
|
70e161bc08 |
docs(qt): playback_qt's header lists OWED PORTS, not "deliberate cuts"
It still advertised the gapless gap after the fix landed — the exact failure the parity inventory was built to stop. A comment that outlives its gap is worse than no comment: it reads as tracked. Three corrections. The gapless entry is gone (it is wired). The blacklist entry claimed "store not open", which is false — artist_blacklist is bound in auth_qt's three session paths, so the store was open the whole time and only the consumption was missing. And the framing: these are debt with an owed port (TRACK-RULES rule 8), not scope decisions, so each now carries its PARITY-DEBT.md row and its severity. New cuts go in that file, not in this header. cc |
||
|
|
809dafce1a |
fix(qt): answer the engine's gapless request instead of advancing after silence
The setting was connected and the backend was working. The log says so in one line: "Gapless: approaching end of track (54s/64s), requesting next" — the audio thread raises `gapless_ready` ~10s before the end (player/mod.rs:3911-3929, gated on `gapless_enabled`) and waits for the frontend to hand it the next track's bytes through `Player::play_next`, which appends to the same rodio sink and is what makes the transition sample-accurate. Nothing in this port answered. `playback_qt`'s own module header recorded the cut from the POC phase — "gapless prefetch (`play_next`) ... out of scope. Auto-advance therefore plays each successor at fetch time (audible gap between tracks) — the engine's gapless path is NOT wired". So every advance fell through to the end-of-track edge instead: on Dark Side of the Moon, Speak to Me ended at 07:21:40.8, the poll noticed at 07:21:41.7 and the successor's initial buffer took another 300ms — a second of silence exactly where that album has none. Ported from playback.rs:5362-5476, both arms: - streaming: peek_upcoming(1) -> fetch_for_gapless_resolved (L1/L2 player cache -> network; the offline tier is None here for the same reason every other play path in this module passes None) -> play_next. - local: resolve the path from the ephemeral store or library.db, skip CUE virtual tracks (they share one album image file), then play_next_dsd for .dsf/.dff and raw bytes otherwise. The reference calls `qbz_dsd::is_dsd_path`; `qbz-dsd` is not a dependency of this crate and local_playback.rs:139 already makes the same call from the extension, so this follows the port's own idiom rather than adding a crate. - the `gapless_requested_for` guard, cleared on the track-change edge so a track reached BY a hand-off can arm its own successor. - the stop-after guard, kept 1:1 although it is a no-op today (this port has no UI that marks a track): without it the engine hands off seamlessly and the marker never fires, which is the trap the reference documents. Nothing in qbz-audio or qbz-player is touched — this consumes `play_next` / `play_next_dsd` / `fetch_for_gapless_resolved` exactly as the Slint does. The seamless hand-off needs no new detection: the engine changes `track_id` while `is_playing` stays true, and the existing track-change edge already reconciles the queue pointer through `sync_current_to_id` and refreshes the card. The end-of-track edge cannot double-fire on it either, since it requires `was_playing && !is_playing`. cc |
||
|
|
01f3819da1 |
fix(qt): the now-playing audio button opens the Normalization + Gapless flyout, and reads the real state
It was not a flyout and it did not read the persisted value, which together
made normalization impossible to turn off.
- `shell/AudioSettingsMenu.qml` (new) ports the `audio-menu` PopupWindow both
bars mount (PlayerBar.slint:686-705, PlayerBarSmall.slint:635-712): 252 wide,
two 44px ToggleRows with 12px side padding and a 12px gap to the switch,
label text-primary 13px over hint text-muted 10px at spacing 1 — the numbers
are PlayerBar.slint's file-private ToggleRow (:80-115).
- `PlayerBar.qml` had `normTouched` / `normLocal` and toggled normalization on
click. `NowPlayingBarSmall.qml` was worse: `property bool normOn: false`,
local state that started at false no matter what was persisted, so the FIRST
click on that button always sent `normalization = true`. Both now just open
the flyout. `active` mirrors normalization on the full bar and is neutral on
the Small one, which is the reference's own asymmetry
(PlayerBarSmall.slint:628-630 states it out loud).
- Gapless had no now-playing control at all.
The local shadow state existed because the document never carried the truth:
`publish_settings()` ran ONLY on `navigate_to("settings")` and on a language
change, and no handler in `settings_qt` ever republished. So until the user
opened Settings the shell read an empty document, the control drew OFF while
the backend had it ON, and the first interaction wrote the wrong value back.
Slint has no equivalent hole — `SettingsState` is a global seeded at startup.
So: seed the document at shell entry, and republish after every audio apply.
Verified by effect, not by diff — the boot log now reports the snapshot as
5006 bytes carrying the persisted `normalization` and `gapless`, before any
view is opened.
Seeding at shell entry also seeds the now-playing stamp's two output LEDs,
which `publish_snapshot` derives from the same audio settings.
cc
|
||
|
|
494596616e |
feat(qt): port the Blacklist manager for artists and albums
The port had a 67-line settings row showing two counters and a header saying the Manage chevron was "NOT shipped — this port has no Blacklist Manager view for it to open". The Slint has 1,114 lines of manager plus the consumer surfaces that make the feature mean anything. - src/artist_blacklist.rs is a 1:1 port of the reference's per-user lifecycle, including the fail-open asymmetry (is_enabled reads TRUE when there is no session, everything else false/empty) and the byte-identical NO_SESSION_ERR. src/reco_dismiss_qt.rs carries the reco-dismissal store. Both are bound in ALL THREE session-activation paths — the offline one included, because the reference records that Tauri never bound it offline and blacklisted artists leaked into offline surfaces — and torn down on logout, or the next account inherits them. - The manager view: three tabs (bordered, ADR-008 — no pills), search, sort, the enable toggle, the disabled warning banner, the four body branches per tab in their load-bearing order, windowed lists, and the clear-all confirm. Reached from Settings only, exactly like the Slint; no sidebar entry. - Every one of the ten store mutations publishes TWICE — its own document and publish_settings() — because the Settings counters live in a different document and nav_qt::back() re-runs no per-view load. Without it, Manage -> unblock -> Back showed pre-mutation counters. The Slint gets this free from a shared global. - The search field is driven imperatively, not by a binding: QbzLineEdit's X used to destroy the binding, after which the box went blank while the list stayed filtered. - Consumer surfaces, which is where the feature actually lives: the album card and album list row gained "Block this album", the artist card gained the 3-arg "Not interested", the album header menu gained Block/Unblock swapping on state, and the artist page gained the Show/Blacklist flip plus the amber hidden banner with its "Show artist" button. album_qt and artist_qt now seed isAlbumBlocked / isBlacklisted at document build time — without the seed an already-blocked album offered "Block this album" and the first click UN-blocked it, the same lie the pin glyph told before isPinned existed. - search_qt passed &Default::default() for both blacklist snapshots, so nothing was filtered anywhere and a greyed row still played. It now passes the real snapshots at both call sites. - The 17 toasts the spec had dropped for want of a toast host are wired: that premise stopped being true when toast_qt landed, and a Clear All that failed with NO_SESSION_ERR was completely silent. Their msgids are in all eight catalogues; the five album-axis ones existed only in es and nl. Also fixed in passing, pre-existing on this branch and unrelated to the blacklist: ArtistView's library tab read `libTracks` unqualified from a child of the object that declares it, which QML resolves against the component root, not the enclosing object — so the "Tracks" heading threw a ReferenceError and never rendered. Found by forcing the artist route in the offscreen smoke, which no earlier gate did. cc |
||
|
|
acf23abc9b |
feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
|
||
|
|
d4667f5ea9 |
perf(qt): rounded covers use a real mask, the spectrum ticks at 62Hz, the derivative cache is bounded
The track's "ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the
software path this port runs on" doctrine was measured FALSE on 2026-07-29:
qbz-qt runs on the GPU (OpenGL RHI, threaded render loop, vsync 5.56ms). The
claim came from an offscreen session, and QT_QPA_PLATFORM=offscreen forces
software by definition. Every design it justified needed re-deciding.
- RoundedImage was a Canvas (renderTarget: Canvas.Image) that rasterised with
QPainter on the CPU AND uploaded a texture per repaint, for every cover in
the app, purely to round a corner identical in every frame of that image's
life. It is now layer.enabled + MultiEffect { maskSource } — an FBO rendered
once and cached for static content. Rectangle { radius; clip: true } cannot
do this: clip in Qt Quick is a RECTANGULAR scissor and ignores radius.
maskThresholdMin/maskSpreadAtMin are 0.5/1.0, NOT the 0.0/0.0 that looks
natural: 0.0 collapses Qt's mask smoothstep to step(0, alpha) and every
pixel with any alpha snaps fully opaque — measured 0 antialiased pixels at
radius 40 against the Canvas's 292. At 0.5/1.0 it is 308, ramp [0,51,219,255].
A software machine keeps the Canvas arm (GraphicsInfo.api), and
QBZ_QT_ROUND_MODE=canvas pins it on a GPU machine where the mask misbehaves.
- VizSettle drove the spectrum from FrameAnimation, i.e. every rendered frame:
180fps on this box for data the producer publishes at TARGET_FPS = 30. Now a
16ms Timer; the wall-clock maths is unchanged.
- SpectrumBand's per-bar y was a SECOND JS binding derived from height. An
anchor does the same arithmetic in C++ with no binding at all, and each FFT
frame is materialised into a plain array once per publish instead of being
read element by element across the JS/C++ boundary. Bindings per tick: bars
28 (was 56), waveform 48 (was 96), energy 5 (was 10).
QQuickPaintedItem was investigated and rejected with evidence: cxx-qt 0.7.3
emits X(QObject* parent) : base(parent), which does not compile against
QQuickPaintedItem(QQuickItem*), paint() is pure virtual, and there is zero
precedent for a custom base class in this workspace. Qt 6 ShaderEffect takes
no GLSL, only precompiled .qsb, which would make the build depend on a Qt
tool whose path differs on macOS.
- PlaylistCollage point-sampled its mosaic in its own Canvas
(Context2D.drawImage does not filter when it scales — measured RMSE 0.000
against nearest-neighbour). It now draws through RoundedImage.
- The scaled-derivative cache had no eviction at all and the .jpg -> .png key
change had orphaned 1251 files. Boot housekeeping sweeps the orphans once and
bounds the directory at 64 MB, LRU by mtime. Verified by effect: first run
deleted 1259 files / 10.05 MB, every run since reports 0, and the directory
is 14 MB. The eviction is only survivable because RoundedImage clears
_scaled on Image.Error and re-requests.
- For You artwork patched the rails in place instead of republishing the
document, which was resetting contentY. HomeView is destroyed on every
navigation, so the patch map is re-handed on mount through
QbzHome.refreshForyouArt() — otherwise the Radio rail and the Spotlight hero
went blank for the rest of the session after one navigation, and the skeleton
pulse Timer stayed pinned ON, defeating the fix it belonged to.
- Spotlight's placeholder gated on the local path instead of the remote url, so
it flashed a glyph on a cold cache where the Slint does not.
The retired doctrine is corrected wherever it was load-bearing for a decision.
The pre-baked icon tints are deliberately NOT reopened and no drop-shadows are
added: both are visual changes, not perf fixes.
cc
|
||
|
|
0c159c141c |
feat(qt): For You gains Qobuz Mixes pages, Artist Spotlight and Radios
Ports the three sections the tab was missing, against the Slint implementation rather than invented: the section rails, the cards, and the mix landing page with its navigation seam so back/forward behave. The mixes reuse the shared crates for their requests -- the Slint app already makes them, so nothing new talks to Qobuz here. Also carries the qt-run.sh portability work needed for the Mac mini: `free`, `nproc`, `pgrep` and `timeout` all differ or are simply absent on BSD/macOS. Two of those bit during the first Mac build. `pgrep` exits 1 when it matches nothing -- the normal case -- and under `pipefail` that killed the script before a single line of output, which reads exactly like a build that did nothing. And `timeout` does not exist on macOS at all, so the smoke produced a one-line log over which the grep cheerfully reported "0 QML complaints": a test that never ran, rendering as a pass. The smoke now fails outright on a log that short. cc |
||
|
|
a3b89107fc |
fix(qt): table headers line up, the mini rail is centred, tooltips have a background
Three of the owner's visual reports, one theme: geometry that was derived in two places and drifted. The track table laid its header out from the LABELLED columns only, so the heart and the overflow menu took no space there and every label sat right of the column it names. Header and rows now come from one shared set of widths, including the responsive arms -- if the header hid columns on a different condition than the row, the drift would come back only on narrow windows. The collapsed rail goes back to Slint's 64px. It had been 50 by owner request, and the measurement showed why that was the actual defect: the rail centred its row at 25 while the header's own sidebar toggle, sitting directly above it, centres at 30. Symmetric padding inside a 50px rail still reads as misaligned because the eye compares the glyphs to the header control, not to the rail; chasing it from inside bought the alignment at the price of a visibly asymmetric hover pill. At 64 both references land 2px apart and the padding is symmetric again. The row stays a literal 34 rather than being derived from the width -- deriving it is what coupled the two tokens and would have silently grown every row to 48. Tooltips: the port had none, while Slint has a shell-level overlay. This adds the overlay rather than a Popup per call site. cc |
||
|
|
6b5226acf9 |
fix(qt): covers are actually downscaled, and non-square art keeps its ratio
The owner's clue was the whole diagnosis: the same image looked fine at 220px in the hover preview and bad at 150px in a card. So not the source, and not the URL variant either -- every Qt producer picks exactly what Slint picks, `large` (600px) into a 200px cell, 3x headroom. `artwork_qt` was producing the right derivative and `RoundedImage` was drawing the untouched 600px original next to it. `Context2D.drawImage` does not filter, so every card was a nearest-neighbour downscale: measured on a card cropped out of the running binary, RMSE 0.000 against a point-sampled reference. The preview looks right because it is a plain Image with smooth+mipmap and never touches this path. Three defects were latent behind that dead derivative: `thumbnail(w,h)` does not preserve aspect (46 square covers on disk written as 32x28), `to_rgb8` matted transparent logos onto black, and the file was JPEG q75 -- more damage than the resample it was meant to cure. Qobuz playlist art is 800x380, and both the header and the card were stretching it to fill a square. It now keeps its ratio, and the uncovered area takes a gradient derived from the image itself, reusing the ambient colour pipeline rather than starting a second one. cc |
||
|
|
d5bf4398ac |
fix(qt): Library sub-tab switch no longer segfaults, and the pane keeps its corners
Three identical faults in dmesg over three days, all `segfault at 8 ... libQt6QmlModels.so.6.11.1[420c5]`. `nm -DC` puts that offset in `QQmlDelegateModel::model()`; the QVariant return means sret, so %rsi is `this` and address 8 is a null pointer plus the member offset. `QQuickItemView::model()` reads its d-pointer, checks a QPointer guard and then calls straight through without testing the value. The re-entry is `onContentYChanged`, not `onModelChanged` as first assumed: `setModel()` emits it from inside the window where the old delegate model is already gone. Switching to Labels re-derives the model AND clamps contentY to a shorter list, which is the emit -- hence the intermittency, since at contentY 0 nothing is emitted and nothing crashes. Reproduced in plain qml6 with no app and no login, and bisected: readback in onContentYChanged dies, in onModelChanged survives 401 swaps, cached array survives everything. The fix is to stop asking the view for its own model. A tree-wide audit found `.model` is the only member that is unsafe this way in 6.11.1 -- count, contentHeight, indexAt and itemAtIndex all null-check -- and the three LocalLibrary tabs already cached their arrays, which is why this stayed a Library-only bug. Also: `clip: true` is a RECTANGULAR scissor in Qt Quick and does not follow `radius`, so the album/artist header band painted over the content pane's rounded corners. Slint clips to the rounded shape on the same element, which is why the identical structure is correct there. The mask sits OUTSIDE the frame rather than at a high z, because ADR-009 pins in-pane modals at z >= 3000 with no ceiling and a z fix would be a standing arms race. cc |
||
|
|
f3f0acc192 |
fix(qt): favorites and pins tell the truth on every surface
The reported symptom was a 404 un-favoriting an artist. The cause was one
word: the shared client builds its query key as `format!("{}_ids", ty)`,
so it wants the SINGULAR noun, and every Qt call site passed the plural.
`artists_ids=` is not a parameter Qobuz knows, so /favorite/delete
answered 404 -- and /favorite/create had been failing just as silently in
the other direction. The trap is that the SAME identifier takes the
plural for /favorite/getUserFavorites, which is why the migration read
right and was wrong; label reads were the mirror image, sending the
singular where that endpoint wants plural and 400ing on every page open,
so a label could never be unfollowed.
Playlists were worse than wrong -- they went to /favorite/create at all.
That endpoint has no playlist_ids; Slint routes them to library.db and
says so in a comment. Rerouted.
Fixing the write then exposed the display: the toggle read a favourites
cache that only filled after the Library view had been opened once, so on
a fresh launch a favourited album drew an EMPTY heart and the click
REMOVED it. A backend fixed without its display is worse than neither,
because the two now disagree. So every producer stamps its real state at
document-build time from the same authority the write uses, and there is
one settle point every heart reconciles through.
Pinning gets the same treatment plus the fan-out it was missing, and both
now patch badges by signal instead of republishing a document -- a
republish hands `model:` a new array, which resets scroll and tears down
the delegate model.
cc
|
||
|
|
82b88ee8e0 |
chore(qt): add scripts/qt-run.sh
The Qt-track sibling of slint-run.sh, and deliberately not a copy of it. No memory tiering and no cgroup cap: qbz-qt does not depend on qbz-ui, so the 20-30 GB compilation unit never enters this graph. That is the point of the track. No RUSTFLAGS and no +nightly either. This worktree's target dir was built on stable with rustflags=[], and RUSTFLAGS is a fingerprint input, so exporting mold would buy a from-scratch rebuild of the workspace to save seconds on a two-minute incremental. MOLD=1 opts in and warns first. The two QML audits run before the build, because cargo check sees nothing of the QML: a missing component or a bridge member that does not exist compiles clean and throws when the view opens. They take a second. SMOKE=1 runs the track's offscreen gate; a guard refuses to start while another cargo/rustc is alive, since one Slint build in a sibling worktree is enough to freeze the box. cc |
||
|
|
89c54cd5bd |
feat(qt): restore and persist the main window geometry
The reported symptom was titles eliding early in every bar mode. Root
cause was not the breakpoints: Main.qml pinned 1280x800 on every launch
while Slint restores window_width from the shared ui_prefs.json (1716 on
the owner's profile). 1280 sits one tier below the 1366 breakpoint, so
Classic's song card capped at 446px instead of reaching its 560px cap --
the ~114px of early elision.
Restoring alone would have fixed that. Saving is the half that makes the
feature real, and it is where the risk lives, so it is built to Slint's
semantics rather than invented: floating-size-only keys, the app minimum
as a gate, the >0.5px dirty check, maximized only ever re-applied.
Two things Qt makes harder than winit and both bit:
Visibility is ONE enum where Minimized replaces Maximized, while winit
keeps them orthogonal and still reports maximized while iconified. So
the flag is latched from the last non-transient visibility. The first
attempt read that through a derived property inside the handler for the
same signal that dirties it, which is one transition stale -- it passed
grep, qmllint and rustfmt, and was caught only by driving real
transitions under qml6. It is a function now; a function has no cached
value to be stale.
And the writes share a file with the shipping Slint app. A truncating
fs::write plus a json!({}) fallback on parse failure meant either side
could flatten the other's document: Slint reading inside the truncate
window falls back to UiPrefs::default() and writes those defaults back.
Every writer in the crate now goes through one atomic read-modify-write
(tmp + rename), and a file that is present but unparsable aborts the
save instead of being rebuilt from nothing.
The Slint side keeps its own non-atomic write, so the cross-process race
is narrowed, not closed.
cc
|
||
|
|
81c43a114e |
fix(qt): glyph colours follow the theme, on light palettes and on accent fills
Two defects with one cause: a tint name that means a frozen hex. "primary" is the legacy alias of literal #ffffff, not Theme.text-primary, so every control passing it drew a white glyph. On the eleven light themes that is white-on-light -- the header back/forward chevrons, the sidebar toggle and the carousel arrows the owner marked. They now pass the theme-following names, which icon_tint_qt bakes at runtime. The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that predate the runtime tinter collapse into those names, and the qrc floor underneath them becomes polarity-aware: dir_for() returns "" whenever the cache dir is unavailable, a bake fails or the theme document has not been published, and that path used to resolve textPrimary straight back to the white bake -- reproducing the bug it was fixing, app-wide, in the degraded state. On accent fills the fix is a deviation from Slint, approved by the owner after measurement. A fixed white glyph on Theme.accent falls under 2.6:1 on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast. Slint hardcodes #ffffff there (CircleAction.slint:72 and three others), so the bug is upstream. accentText alone is not the answer either: it is picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn, worse than the black it replaces. So QbzTheme picks the best-contrast tint by real sRGB luminance against the live accent, and the labels beside those glyphs use its colour twin -- BrowseGenreButton and LibraryView were drawing a white glyph next to an accentText label inside the same pill. Rounds out the two parity items the same files owe: QbzNavButton gains the ambient arm from Carousel.slint (wired at the six carousel sites, not left dead), and QbzCircleAction rounds its glyph to whole pixels the way CircleAction.slint does. cc |
||
|
|
8af04c5272 |
fix(qt): NPB Small mounts the shared song card
The Small bar hand-rolled a reduced copy of the song card inline: album art plus a two-line title/artist block. All three features the owner reported missing live in SongCard.qml and only there -- the "layers" playback-context glyph with open-context(), the album name in the meta row, and the hover area that publishes the floating art preview. The overlay itself was already mounted shell-level with no bar-mode gate; it simply never had a writer while Small was loaded. PlayerBarSmall.slint mounts the shared SongCard with text-center, so this is the port catching up rather than a redesign: art-size 37, art-border 3px surface-card, show-badges false, title 13px, art-text-gap 9. The 18px meta row fits the 39px controls row with ~1px clearance, and the loading scrim on the cover comes along with it, as it does in Slint. Also folds the responsive tier arms into shared helpers so the four bar modes cannot drift apart on the 1366/1920 breakpoints. cc |