Commit Graph
47 Commits
Author SHA1 Message Date
vicrodh 64ec3e2bc8 fix(updater): restore Qt release checks and signed installation
cx
2026-09-12 15:20:23 -06:00
vicrodh 74e1610433 feat(orbit): add shared library foundations and gated host tools
Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.

Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.

Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.

Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.

cx
2026-09-12 13:02:29 -06:00
vicrodh 6b81cfa84d fix(share): copy links through QClipboard, not arboard (#684)
Copy link failed on GNOME + Flatpak: arboard's Wayland path needs the
wlr/ext-data-control protocol, which Mutter does not implement, so it fell
back to X11 — reachable natively through XWayland, unreachable inside the
sandbox ("clipboard unavailable: X11 server connection timed out"). KWin
has the protocol, which is why the failure never reproduced on Plasma.

Replace it with a C++ shim (cxx/clipboard.cpp) that queues the write onto
the GUI thread and hands it to QClipboard: the base wl_data_device protocol
every compositor implements, no extra sandbox permission, and the offer
lives as long as the application (the #514 lifetime rule, without the
long-lived arboard instance). arboard is dropped from the crate.

Verified on Plasma/Wayland and on an Ubuntu GNOME/Wayland VM with DISPLAY
unset (the sandbox condition), where the copy now lands.

cc
2026-09-11 13:53:27 -06:00
vicrodh fd421044bc fix(renderer): rescue failed Linux Auto presentation per launch
Probe Qt's default backend in a disposable graphics-only child before runtime and the main window. After proven failure, allow one software presentation attempt without persisting a renderer preference. Preserve explicit backend policy and saved-GPU validation.

Bound and reap children, keep deep links in the parent, and avoid blocked diagnostic pipes. Cover healthy/failing decisions, overrides, missing executables, paths with spaces and watchdog cleanup in the passing Qt suite.

cx
2026-09-06 05:08:17 -06:00
vicrodh 7ea01a7e67 fix(startup): bound and cancel the Linux instance probe
Use one two-second deadline across native Unix connect, D-Bus authentication, name ownership and URL/window forwarding. Own and shut down the transport on cancellation, preserve unconfirmed links, and keep the primary server alive.

Cover stalled and delayed phases, filesystem/abstract buses, ownership release and early UI handoff with isolated fixtures. All 733 Qt tests pass; async-io features remain unchanged.

cx
2026-09-06 05:05:27 -06:00
vicrodh a39ea8c12d feat(settings): Import / Export section with portable blacklist and account migration
A new top-level Settings section, Import / Export, gathers everything that
moves data in or out of this install:

- SETTINGS PORTABILITY: the settings bundle export moves here from
  Developer, with a note on what the bundle does not hold (favorites,
  playlists, blacklist, purchases).
- BLACKLIST: export the blocked artists and albums as a JSON one user can
  hand to another; import merges additively and never touches the enabled
  flag (qbz_app::settings::blacklist_portable, frontend-agnostic).
- ACCOUNT MIGRATION: create a snapshot while signed in with the old
  account, then migrate it into the account signed in now, with the local
  profile copy and its three opt-in toggles (media servers, scrobblers,
  listening history, all on). The disclaimer states that subscriptions and
  purchases do not move. Progress and results are status lines in the
  settings document; the favorites cache re-warms and the sidebar reloads
  after a run, the local half asks for a restart.

Also: the header shows a translated "Member" for accounts without a
subscription, and the login screen says what a member can and cannot do.
50 new strings in all eight catalogs.

cc
2026-09-02 08:24:02 -06:00
vicrodh d3cf7ae53d chore(qconnect): wire LAN runtime dependencies
cx
2026-08-30 22:22:31 -06:00
vicrodh 3785a7eb40 chore(deps): rfd 0.17 (xdg-portal; the async-std feature is gone)
cc
2026-08-29 08:53:42 -06:00
vicrodh 6d8c951a38 chore(deps): rusqlite 0.40 (u64 casts; a latent two-statement execute fixed)
rusqlite 0.40 removed ToSql/FromSql for u64 (SQLite integers are i64):
every u64 column now reads/writes through an explicit i64 cast. It also
rejects a multi-statement execute() instead of silently running only the
first statement — which is what catalog.rs's editions/logical_albums
prune had been doing all along; it is an execute_batch now, so the
logical_albums prune finally runs.

cc
2026-08-29 08:36:21 -06:00
vicrodh 912638c387 merge: integrate Windows port from PR #725
cx
2026-08-28 04:18:06 -06:00
vicrodh 6e62724b4b fix(qt): preflight preferred GPU presentation
cx
2026-08-27 17:54:27 -06:00
vicrodh afa32d9bba feat(windows): single instance and protocol deep links
The Windows sibling of single_instance_qt.rs. Same three entry points and the
same present-or-defer behaviour, so main.rs reads identically on both.

A named mutex arbitrates and a named pipe carries the message, because those
are different problems. CreateMutexW answers "did it already exist" atomically,
with no window for a second launch to slip through. A pipe cannot be asked that
without FILE_FLAG_FIRST_PIPE_INSTANCE, and QLocalServer never passes it
(PIPE_UNLIMITED_INSTANCES), so two processes could both believe they were the
server.

The plan asked for QLocalServer here, which meant a new Q_OBJECT file in the
moc list and .qt_module("Network"). A raw named pipe is the same primitive
underneath with none of that build surface, for a protocol two verbs long.

The pipe name is QUALIFIED BY SESSION ID, and that is not cosmetic: the mutex
is `Local\`, which is per-session, while pipe names are machine-global. With
one fixed name and two users signed in, each takes its own mutex but only the
first creates the pipe -- the second primary then serves nothing and ITS later
launches would forward to the OTHER USER'S process. PIPE_REJECT_REMOTE_CLIENTS
because the namespace is reachable over SMB and nothing about a second launch
is remote.

What arrives on the pipe is VALIDATED before it is acted on. It is not argv:
any process running as this user can write a line, and the payload otherwise
went straight to link resolution. The read is bounded at 4 KB for the same
reason.

`qbz://` is rewritten to `qobuzapp://` rather than taught downstream. The
resolver knows qobuzapp and the Qobuz web shapes and nothing else -- a live
test of the forwarding path came back "unsupported or invalid music link" for
a qbz:// URL, and a test in this file already pinned that is_qobuz_link
excludes it. The two carry the same album/track/artist shape, so a prefix swap
is the whole translation, and the MSI's registration finally means something.

A parse alone is not a gate: Url::parse("qbz://") SUCCEEDS, a valid scheme with
an empty everything, so the check requires a non-empty authority -- which is
where every accepted shape carries its target.

The URL validation in select_link is Windows-gated. ShellExecute can split a
link across argv there; Linux and macOS have accepted whatever matched the
prefix since this shipped, and tightening that from a Windows port would change
behaviour on platforms that never asked.

present_or_defer re-reads UI_READY after publishing PENDING_PRESENT. bind_ui
can flip the flag and run its own swap in between, leaving the pending bit set
with nobody to consume it and a second launch looking ignored.

Verified on the box: a second launch carrying a deep link exits 0 in about four
milliseconds, the primary logs the handoff and resolves the rewritten URL.
571 tests pass.

cc
2026-08-27 17:42:53 -06:00
vicrodh 27022fbf46 fix(qt): offer notifications only where a toast can actually arrive
`system_notifications_supported` answered `cfg!(windows)` in an earlier draft
and then a flat `false`; both were wrong in opposite directions. It now asks
the machine.

Setting the process AppUserModelID is necessary and NOT sufficient. An
unpackaged desktop app receives toasts only once Windows can RESOLVE that id,
through either a Start-menu shortcut carrying System.AppUserModel.ID or the
matching AppUserModelId registry key. The MSI writes both. A portable unzip has
neither, and every toast it raises is dropped in silence -- no error, no log,
nothing to point at.

So the row is offered on the strength of the registry key, read once at
document-build time. An MSI install sees it and gets the setting; a portable or
dev build does not, and the row stays hidden rather than presenting a switch
that can never produce a notification. That is the same "renders, persists and
drives nothing" failure the capability flags exist to prevent, and the reason
the flag was held back until the MSI existed to satisfy it.

Linux and macOS keep their compile-time answer.

cc
2026-08-27 17:00:23 -06:00
vicrodh 9b0d8d7e0f feat(windows): AppUserModelID at startup and the toast arm
Two halves of the same identity.

`SetCurrentProcessExplicitAppUserModelID` runs before `QGuiApplication::new()`,
which is the documented window: Windows caches the identity with the first
window and a later call fails. Without it the taskbar treats each launch as an
anonymous window and does not group it with the Start-menu shortcut. The
HRESULT is logged on failure -- when this breaks, taskbar grouping and toast
delivery both break silently, and the only symptom would otherwise be
"notifications do not work" with nothing to point at.

The notification arm follows the macOS one, with `app_id` matching the AUMID.
notify-rust's Windows backend forwards summary/body/app_id/image and SILENTLY
DROPS `actions`, so no buttons are attempted: they would look implemented and
do nothing.

The artwork cache is shared rather than duplicated -- its four `cfg` gates now
include Windows, which also retires an unused-import warning there. Its
`file://` resolver gained the two Windows shapes: the empty authority's slash
in front of a drive letter (`/C:/x`, which PathBuf keeps and then never
resolves), and a UNC authority (`file://nas/share/x`), which was being treated
as a path relative to the working directory.

`system_notifications_supported` deliberately stays false on Windows. An
unpackaged desktop app only receives toasts once Windows can RESOLVE its
AppUserModelID, which needs a Start-menu shortcut carrying
System.AppUserModel.ID -- written by the MSI, which does not exist yet. Setting
the process identity is necessary and not sufficient. Advertising the row now
would let the setting be enabled in exactly the builds where nothing can ever
appear. It flips with the MSI.

cc
2026-08-27 13:37:25 -06:00
vicrodh 7135c841cb build(windows): embed the icon, VERSIONINFO and a longPathAware manifest
winresource, not winres: winres has been unmaintained since 2021 and this
is its maintained fork. Gated to cfg(windows) as a target build-dependency,
so no other platform resolves it at all.

The manifest declares longPathAware and NOTHING else. Qt 6 sets
PerMonitorV2 itself at startup and a manifest DPI declaration is immutable,
so the two together either log a permanent COM error 0x5 or degrade
scaling - Qt owns DPI awareness.

The .ico carries 256/128/64/48/32. The four small entries are BMP and only
256 is PNG: measured on this box, System.Drawing.Icon (GDI+) throws
ArgumentOutOfRangeException in ToBitmap() on a PNG-payload entry, while WIC
decodes it. 256 has to be PNG because the directory encodes its size as 0.
Pixels verified against the source PNG at four points including an
asymmetric one, which is what proves the BGRA order and the bottom-up flip.

cc
2026-08-27 03:46:29 -06:00
vicrodh 69ed612077 refactor(frontend): retire Slint application
cx
2026-08-26 11:10:18 -06:00
vicrodh d39edf5850 feat(library): bootstrap derived catalog
Build the catalog side by side from read-only local, Plex, Jellyfin, and Subsonic cache snapshots after the first useful Qt frame.

Persist bounded batch checkpoints, preflight disk capacity, validate materializations, and activate generations atomically while preserving the existing reader as fallback.

cx
2026-08-23 07:23:23 -06:00
vicrodh bbce651d79 feat(qt): a Rip button on an open CD, and it asks where to put the album
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.

The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.

While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.

Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.

Four new strings in all eight locales.

cc
2026-08-21 02:27:40 -06:00
vicrodh d9893b659a feat(qt): open the audio CD in the drive and play it
`Open > Open audio CD` reads the disc, builds an ephemeral session and lands
on its tab. Nothing touches library.db, which is the whole point of the
ephemeral path: a disc is content from outside the index that leaves with the
plastic.

It does not invent metadata. CD-DA carries no titles, and the disc measured
here reports its MCN as all zeros, so tracks are "Track N" and the album is
"Audio CD". A DiscID lookup is the obvious next step and is deliberately not
faked: a wrong title is worse than an honest number.

The feeder is the interesting part, and its liveness check took three designs
because the first two were wrong in opposite directions — both caught by
driving the app, neither by any test:

  1. `current_track_id() != play_id`: `play_streaming` posts to the audio
     thread and returns, so the id is not ours yet and the feeder retires
     before reading a sector. Nothing played at all.
  2. The same test with a wall-clock grace: a DEADLOCK. That field is stored
     only once the audio thread starts playback, which needs the initial
     buffer, which is what the feeder is filling. Waiting on it is waiting on
     ourselves; the grace expired and the track stopped mid-play.
  3. A feeder slot the audio thread plays no part in. A newer disc play claims
     it and the older thread retires on its next block. The acknowledgement
     path stays for "something else superseded us", but only AFTER the player
     confirms our id, and with no timeout — a timeout is what kills a healthy
     feeder.

A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and
reusing it would either lie in the header or promote 16-bit samples to 24.
The shift is arithmetically lossless but it makes the DAC open at 24 bits for
a 16-bit source, which is precisely the silent conversion a bit-perfect path
must not contain. `play_streaming` also does not publish bit depth, so the CD
states 44 100/16 explicitly instead of inheriting the previous track's.

Driven end to end on the owner's disc: seven tracks with the right durations,
CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive
mode, playback past 2:25 with no read errors, and two rapid skips retiring
their feeders in ~160 ms each.

Seven new strings in all eight locales.

cc
2026-08-20 23:29:21 -06:00
vicrodh 63a4f2daf1 feat(qt): the Local Library union, the settings store and the sweep
Three pieces that only mean anything together: rows in the shared cache,
a union that shows them, and settings that decide whether either
happens.

THE UNION. `get_albums_metadata_page` grows a second, INDEPENDENT
attachment. `plex_cache` stays; `remote_cache` is the shared mirror, and
they attach separately on purpose — a missing Plex cache must not cost
the user their Jellyfin rows. `attach_best_effort` is extracted because
the failure mode of a botched ATTACH is a silently local-only library,
and a failure stays non-fatal: refusing to list any albums because one
server is off turns "Jellyfin is unplugged" into "your library is
empty".

The union takes an ENABLED-SOURCES list, not just a path. The mirror
holds every remote source, so the master toggle has to be a filter
rather than a detach: turning Jellyfin off must hide Jellyfin's rows and
leave Subsonic's alone. That predicate is interpolated into SQL, so it
is VALIDATED rather than escaped — only [a-z] survives, an all-invalid
list yields `0` (show nothing, the safe direction), and a test feeds it
`x'; DROP TABLE local_tracks; --`.

The remote group key is `<source>:<album id>`, matching Plex's
`plex:<hash>`. That namespaces two servers using the same album id AND
lets a source claim an album card that arrives with no source word,
which is every card that round-trips through a QML string property —
`recognises` grew that arm and `claim` strips the prefix, because
carrying it inward is how Plex ended up with two album keys only one of
its queries understood (IC-2).

THE SETTINGS. One store for both servers, keyed by a `server` column,
mirroring the shared-cache decision. What each protocol needs differs
and `is_configured` is the one place that knows: Jellyfin needs its
issued token, Subsonic needs a password because it has no session and
re-derives `md5(password + salt)` per request.

Two identifiers are minted once and persisted, and both are
load-bearing. Jellyfin's DeviceId, because re-authenticating under the
same one revokes the previous token — a constant would make two installs
log each other out, a fresh one per launch drops the last run's token.
Subsonic's salt, because rolling it per request makes every cover URL
unique and the artwork cache keys on the URL.

THE SWEEP. In the frontend, where the runtime and the progress UI are,
writing through the cache handle the SOURCE owns so sweep and reads can
never point at different users. Jellyfin gets progress and a delta path
(45.8 s for 4924 rows); Subsonic needs neither (0.81 s for 6678).
`prune_stale` runs only after a COMPLETED full sweep — a delta never
prunes, because it deliberately does not see unchanged rows and would
read every one of them as deleted. `last_sync_at` is stamped only on
success for the same reason.

Verified against the running binary, twice:

  source registry bound: local_tracks=617 plex=connected
    ephemeral_store=yes remote=none configured
  jellyfin: minted a device id for this install
  subsonic: minted a salt for this install

...and on the second launch, zero re-mints and the same values on disk —
which is the property that keeps a Jellyfin session alive.

qbz-qt 428/428, qbz-app 224/224, qbz-library 100/100, qbz-source 88/88.
Release build clean, zero warnings.

cc
2026-08-20 09:20:07 -06:00
vicrodh e6c058c2f2 feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral
Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven
ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed
by the Viz16 stream. Scene rows live in the header view menu (no Off row);
g cycles the ring. A fresh immersive open defaults to Split > Lyrics
(owner ruling).

OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as
type buf and type buf"): every ShaderEffect fragment with its own uniform
layout now ships a paired vertex stage declaring the identical block —
the shared spectrum.vert only ever linked under Vulkan.

RHI hardening: render pass descriptors on the ping-pong targets (the
Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the
backend (!== OpenGL), ribbon sub-uploads carry setSourceSize.

km
2026-08-16 08:22:57 -06:00
vicrodh 21e941bdf6 feat(qt): renderer tier, GPU selection and a real shader build
Closes PARITY-DEBT #83 and #104, and unblocks the immersive shader scenes.

THE TIER. Slint resolves one bool, use_gpu_renderer, and hands it to three
features: the shader scenes, the dynamic background and half of reduce-motion.
This port had none of it, which is why the scenes could not be offered — off
the GPU tier they render black and nothing could tell. QbzShell now carries
rendererApi, gpuTier, shaderScenesAvailable and appBackgroundAvailable, all
written from one latch. Only QML can source it: QRhi picks the backend after
QGuiApplication, so the pre-init code knows only what it asked for.

Per-item GraphicsInfo.api stays where it is. "Can this item draw?" and "should
this feature be offered?" are different questions, and routing the first
through Rust would make every cover wait on a round trip.

SHADERS ARE BUILT NOW. build.rs bakes every .frag/.vert to .qsb with ten
variants and the batching rewrite. They used to be baked by hand and committed
carrying no GLES variants at all, so any GLES context failed them with "No
GLSL shader code found" — which is exactly how the NPB Large visualiser went
blank while this branch was being tested. Adding a scene is now dropping a
.frag in the folder.

GPU SELECTION enumerates real devices through Vulkan and lists them by model,
never by class: a machine with no discrete GPU can no longer be told to use
one. Legacy class keys still resolve, but only against hardware that exists.
Picking a non-default device implies the Vulkan backend, which the row's own
description states in all eight locales — measured as the only route that
keeps a working context: DRI_PRIME lands on llvmpipe and the NVIDIA PRIME
pair lands on OpenGL ES.

The renderer row gains the auto-revert sentinel and the frame-liveness
watchdog it shipped without. The sentinel arms only for a choice that truly
forces a backend, and liveness means frames rendered over a window, not merely
a window that opened.

Also fixes the kiosk toggle overwriting the tier half of reduce-motion.

Verified by 312 tests, both QML audits, the offscreen smoke, and a probe
against the real hybrid GPU: both devices enumerate by name and the stored
preference applies.

cc
2026-08-11 13:59:24 -06:00
vicrodh 8ba9483462 feat(qt): HiFi Wizard — guided DAC setup ported from Slint
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened
from Settings > Audio. It probes the audio stack and renders per-distro
remediations, enumerates DACs with a manual node.name escape hatch,
generates the PipeWire / WirePlumber snippets to copy, and plays four
tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated
read-back. Read-only: it never writes a system file or runs a command.

The logic was unreachable from Qt — every public function in
qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals.
It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core,
which returns plain data. qbz-dac-wizard itself is left untouched.

Rust owns everything it computes plus `open`, so the reset and the probe
are one act; QML owns the step navigation and the four progress
checkboxes, which is how the reference splits them too.

Two deliberate divergences from the reference: the row sits at the end of
OUTPUT rather than BIT-PERFECT, and it is offered on every backend
instead of PipeWire only — someone on ALSA direct is exactly who needs
the walkthrough.

The green glyphs use the accent tint, not success: success has no qrc bake
directory, so a degraded tint set would render nothing at all. Every
success-toned text keeps theme.success (#3fae6a verbatim).

Verified by 309 tests, both QML audits and the offscreen smoke, plus a
forced-route probe that mounts Settings and walks the wizard's steps —
the standard smoke only mounts Home, so it is no evidence for a modal.
The modal was also driven and screenshotted over VNC.

cc
2026-08-11 11:26:50 -06:00
vicrodh 5e1eb46a71 feat(qt): window chrome parity — macOS tray, native chrome, appearance wiring
Three Slint-parity pieces that share the chrome surface:

- macOS menu-bar tray (NSStatusItem, tray_macos.rs) ported from the
  reference — the K2 'macOS is a new implementation' ruling read the
  reference backwards (it avoids muda entirely), and its absence made
  close-to-tray quit the app on the Mac. Plus the custom-chrome window
  mode (macos_chrome.rs): Qt 6.9 ExpandedClientAreaHint /
  NoTitleBarBackgroundHint instead of fighting QCocoaWindow's style-mask
  recompute, traffic-light centring, and the SafeArea inset in HeaderBar.
- Appearance rows that rendered, persisted and drove nothing now push
  live: hide/show title bar, window-controls cluster side, track in the
  window title, invert swipe navigation, auto-theme source selection
  (wallpaper/image/system) with regeneration on source change.
- use_system_title_bar now defaults per-OS (custom chrome on macOS),
  matching the reference — the hard-coded 'true' also leaked into the
  Slint build through the shared ui_prefs.json.

cc
2026-08-10 17:15:18 -06:00
vicrodh 024ed615e8 fix(qt): MainThreadMarker::new() needs objc2-foundation's NSThread feature
Second one the Mac build caught and Linux could not: the type resolved, but
`new()` did not exist — it is gated on `NSThread`
(objc2-foundation-0.2.2/src/thread.rs:210-213), leaving only the unsafe
`new_unchecked`. That is precisely the wrong half: the point of the call is
to ASK whether we are on the AppKit main thread and no-op when we are not.

cc
2026-08-04 16:39:00 -06:00
vicrodh 1b18adcde0 fix(qt): MainThreadMarker comes from objc2-foundation in this generation
The Mac build caught it on the first try, which is exactly what it was for:
`use objc2::MainThreadMarker` does not resolve — in the objc2 0.5 /
objc2-foundation 0.2 generation the type is exported by FOUNDATION
(objc2-foundation-0.2.2/src/lib.rs:152, ungated); it only moved to the objc2
root later. The reference imports it from the same place
(crates/qbz/src/tray/macos.rs:38), which is where this should have been read
from in the first place rather than assumed from a newer API.

The direct `objc2` dependency goes with it: nothing here names a type from
that crate.

cc
2026-08-04 16:37:30 -06:00
vicrodh dc604bdf62 feat(qt): wire the macOS Hide-Dock setting — Spotify behaviour on close
The row rendered, persisted and drove nothing, which is the exact defect
class the miniplayer/tray contract existed to end. The owner named the
behaviour they want: close puts the app in the menu bar AND drops the Dock
icon; minimize still goes to the Dock like any app.

So only the CLOSE path touches the policy. The wiring point is
`QbzTray.setWindowShown`, which QML calls from exactly two places —
`hideToTray` and `showFromTray` — so this needs no third visibility owner and
cannot drift from the real window state.

Hiding drops the icon only when the setting is on; SHOWING always restores
it, with no setting check. A user who turns the option off while the app sits
in the menu bar must still get their icon back, and restoring an icon that is
already there costs nothing.

NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind
cfg(target_os = "macos"), and a cross-check dies in the C build scripts for
want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2
instead — which immediately earned its keep: `NSApplicationActivationPolicy`
lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication
feature does not pull it. The three features here match the reference's own
proven set. First real check is a build on the Mac mini.

cc
2026-08-04 14:55:36 -06:00
vicrodh 721841a4b8 fix(qt): Qt.quit() was vetoed by our own windows, so Quit never quit
The owner reported it three times and their second wording was the whole
diagnosis: "Quit QBZ debe cerrar la aplicación, NO SOLO LA VENTANA."

ROOT CAUSE. `Qt.quit()` in Qt 6 is a REQUEST, not an order. It posts
QEvent::Quit, and QGuiApplication answers it by calling close() on every
top-level window that has a platform handle — hidden ones included. If any
window refuses its close event, the quit is silently cancelled. Two of ours
refuse:

  1. Main.qml's closeOrHide refuses whenever trayLive && closeToTray, and
     hides the window on the way out. That IS "cierra la ventana, no la app".
  2. MiniWindow's onClosing refuses unconditionally, because for the mini a
     close means "leave the mini" — so once the mini had ever been opened,
     quit was blocked regardless of any setting.

It also explains "Close to tray does nothing": with the setting ON, the X
hid (correct) and Quit hid too (the bug), so the two were indistinguishable
and the toggle looked inert. Nothing was ever wrong with that plumbing.

FIX. Both quit sites call Qt.exit(0), which exits the event loops directly —
no QEvent::Quit, no window to veto it, and no need to touch the miniplayer's
onClosing, whose refusal is correct for what it means. A one-shot 5 s
watchdog thread, armed only at the three quit sites, flushes the log and
_exit(0)s if anything downstream ever wedges; it cannot fire in a live
session. main() now names each teardown step, so a future hang says which
statement it hung on instead of going quiet.

closeOrHide reports its decision through the bridge — tray_live,
close_to_tray and the arm taken — because a report of "the tray does
nothing" was not previously separable from "the signal arrived and QML
ignored it".

MEASURED, with waitpid on a child rather than /proc (a zombie reads as alive
and that produced a false diagnosis earlier in this session). Old binary:
alive ≥25 s with the window visible, hidden, and with the mini open. Fixed:
exit 0 in 0.35-0.68 s across eleven driven scenarios — the tray Quit in all
three window states, the drawn X and the app-menu Close with the setting
both ON and OFF, the mini's own close button, and the MPRIS Quit method.

One arm remains static-only: a real WM close event with close-to-tray OFF.
VNC delivers no such event, so the owner's first titlebar X is its first
real execution.

cc
2026-08-04 11:23:41 -06:00
vicrodh ce7f29ff9c chore(qt): ship the binary as qbz, and log the first MPRIS metadata push
The artifact is now `target/<profile>/qbz` (owner, 2026-08-04). The package
stays `qbz-qt`, so `cargo build -p qbz-qt` is unchanged.

⚠ `crates/qbz` — the Slint binary — declares the SAME bin name and shares
this workspace's target dir, so both write `target/<profile>/qbz` and the
last build wins. Survivable only because the Slint binary is never built in
this worktree (the qbz-ui 20-30 GB wall); if it ever is, a smoke against
"the wrong qbz" is silent. Written into Cargo.toml beside the name.

The MPRIS line exists because "Plasma sees nothing" has two very different
causes — the player never registered, or it registered and no metadata was
ever pushed — and the log could not tell them apart. It settled that
question the same day: the owner's log shows `[mpris] metadata pushed:
Holy Diver — Dio (350s)`, so the port's half works and what remains is
desktop-side.

cc
2026-08-04 11:23:07 -06:00
vicrodh 5dd450fb9f feat(qt): MPRIS — the desktop can finally see the player
Owner ruling K3 kept MPRIS out of the miniplayer/tray contract, and its
default shipped. The owner reversed it after smoking the tray: QBZ did not
appear in KDE Plasma's now-playing widget. Plasma reads MPRIS, so without
this the desktop cannot see the player at all.

src/media_controls_qt.rs (new) — a plain module over the SHARED
qbz-media-controls crate, following the qbzd precedent rather than the Slint
one because qbz-qt is also not a Slint app. Outbound: metadata on the track
edge, playback state on the play/pause edge, both carrying position.
Inbound: play, pause, toggle, next, previous, stop, seek and volume, plus
Raise — which calls tray_qt::present() unchanged, the mini-aware verb the
tray contract shaped for exactly this caller.

src/artwork_qt.rs — cached_raw_path, the remote arm of cached_path without
its file_url step, so the art URI is built for MPRIS rather than for QML.

src/playback_qt.rs — four sibling pushes beside the tray's: both arms of
refresh_now_playing, and BOTH is_playing != was_playing blocks. One of two
is the silent half-port, so the grep is the instruction.

Verified on the session bus, not by reading: qbz-qt owns
org.mpris.MediaPlayer2.com.blitzfc.qbz, Identity "QBZ", DesktopEntry
com.blitzfc.qbz — that is what gives Plasma the icon — with CanControl,
CanPlay, CanPause, CanGoNext, CanGoPrevious, CanSeek, CanRaise and CanQuit
all true and PlaybackStatus tracking. Metadata is empty until something
plays, and that half is NOT verified: it needs audio.

cc
2026-08-04 08:42:28 -06:00
vicrodh 56d4387172 feat(qt): system tray via ksni
Block B5 of the miniplayer/tray contract. The tray settings rows have
rendered and persisted since the Settings port and driven nothing; this is
what they drive.

src/tray_linux.rs (new) — the ksni StatusNotifierItem: the twelve icon
pixmaps, the variant ladder, the tooltip, the six-item menu, left/middle
click and scroll, and the qbz-tray-updater thread every mutation crosses.
Note the filename inversion it reproduces: tray-light-* holds the BLACK
glyph, so the "mono-light" setting loads tray-dark-*.png.

src/tray_qt.rs (new) — the portable half: init's three gates, the window
verbs, the 400 ms toggle debounce, the transport dispatchers and
in_gamescope(). toggle_window is mini-aware — while the mini is open a tray
click raises the MINI, because a mini-blind toggle would put the main window
beside a live mini on the first click.

src/tray_bridge.rs (new) — QbzTray: trayLive, closeToTray, and the four
signals the ksni thread reaches QML through. Rust cannot touch a
QQuickWindow, so every verb is a signal.

icons/ — the twelve PNGs, copied rather than include_bytes!'d across crates,
so this crate does not depend on a file inside the Slint binary crate.

src/playback_qt.rs — the three live-update pushes: set_track and clear_track
in both arms of refresh_now_playing (the publisher with 23 call sites, so a
QConnect or cast track change updates the tooltip too), and set_playing in
BOTH is_playing != was_playing blocks, each beside the existing discord_push.

src/settings_qt.rs — tray() widened to pub(crate) for the session-entry read;
the tray_close_to_tray snapshot default corrected from false to true, which
was cosmetic while nothing consumed it and becomes behavioural now; and the
tray-icon-theme select arm re-themes the running icon with no restart.

Cargo.toml + Cargo.lock — ksni 0.3 with default-features off. Never the
tokio feature: the blocking API panics inside tokio and the panic is
swallowed, which is why the two dedicated threads exist.

src/main.rs, build.rs, qml/Main.qml — the registrations, and tray_qt::init
in on_session_entered. That call site is also deliberately the process's
first settings_qt::tray(), because the store binds per user and a read from
the bridge's construction seed would latch an empty one at the login screen.

The Connections block that answers the four signals lands with B6, together
with the window verbs it calls. Until then the tray renders, its tooltip and
menu are live, and its clicks reach Rust and stop there.

cc
2026-08-04 02:47:56 -06:00
vicrodh 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
2026-08-02 16:32:51 -06:00
vicrodh 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
2026-08-01 20:23:38 -06:00
vicrodh 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
2026-07-31 22:10:19 -06:00
vicrodh 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
2026-07-30 17:14:40 -06:00
vicrodh 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
2026-07-29 21:23:45 -06:00
vicrodh 87b18fa7f7 feat(qt): wire local-network casting end to end
The two cast buttons in the now-playing bars rendered and did nothing: no
service, no bridge, no picker. This ports crates/qbz/src/cast_service.rs
onto the shared qbz-cast crate (discovery, both device families and the
media server are the crate's, not reimplemented) and hangs a QbzCast
singleton off it.

Discovery is armed only by openPicker() and stopped by closePicker(), so a
user who never opens the picker sends no mDNS/SSDP traffic. Connecting
stops the local backend through its public seam and re-casts the current
track at the streaming preference clamped by the per-renderer cap.

Playback is routed at four seams in playback_qt (new track, toggle, seek,
volume) and the local poll yields while casting — without that the two
polls fight over the bar every second. Logout and exit tear the connection
down, or the renderer keeps playing after the app is gone.

Deliberate gaps, each named in the module header: no offline-cache tier,
no QConnect suspend/restore, no Plex casting (the Slint carries the same
TODO), and lyrics do not follow a remote position yet.

cc
2026-07-29 00:56:07 -06:00
vicrodh d76f694781 feat(qbz-qt): close the two declared debts on the album and artist pages
The Last.fm similar-albums row exists now. The recommendation building was
already frontend-agnostic in qbz-external-reco, so this only adapts QbzCore to
its catalog trait and publishes the result; the row seeds on the album's artist
and excludes what the two Qobuz rows already show, including ids the
pre-resolution dedup misses. Resolved rows cache per album for thirty days in
the same store the Discover tab uses, so re-opening an album makes no Last.fm
or Qobuz calls, and only a non-empty result is cached — a transient failure
must not hide the row until restart. With Last.fm not connected it resolves to
nothing and makes no network call, so the rail simply never appears.

Relationship rows navigate. They carry a name rather than a catalog id, so the
click resolves through MusicBrainz first and only a CONFIRMED match opens an
artist — landing the user on a same-name musician is worse than a row that does
nothing. The role travels with the name so a match in another role is not
mistaken for this one.

cc
2026-07-28 16:45:45 -06:00
vicrodh 31b1484c01 feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line
file. Slint's is 2,671 lines across five tabs, and the local album detail is a
routed page of its own. This closes that gap and splits the result: the view
becomes a composition root over qml/views/local/ modules, and
local_library_qt.rs becomes a narrow facade over local_* modules by concern.

Plex is wired for real. The port hardcoded the cache path to None, so the union
was local-only and Plex thumbnails were skipped. The settings store binds per
user at session activation, the album and track union includes Plex, thumbnails
resolve through the artwork path, rows carry their source badge, and the manual
re-sync is on the toolbar behind the availability gate — as in Slint.

Tabs: Albums with its quality/format/source filter popup, Tracks paginated,
Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail.
Every list is windowed from the start, artwork id-keyed and evicted, because
16k tracks is exactly where the Slint build freezes.

The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral
folder" pane plays music that was never indexed, and the album page gets its
version picker — a local album with two physical copies now switches between
them without a database round-trip.

Two agents built the QML and the Rust halves in parallel and the QML called
thirty QbzLocal members that did not exist. QML resolves lazily, so each would
have been a control that renders and does nothing; a static cross-reference
caught them before a build ever ran, and they are implemented here.

Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and
the tag editor have no picker or modal in this port yet, and local favourites
are not wired, so those actions log rather than route a local id into the Qobuz
API. Plex playback downloads the part instead of range-streaming it, which is
Slint's own documented fallback.

cc
2026-07-28 15:25:00 -06:00
vicrodh ea644f5e05 feat(qbz-qt): phase 19 — full Settings subnav, Appearance + Integrations, live theming
Theme system as data: qbz_theme registry (36 themes + auto/custom)
resolved from the shared ui_prefs slug into a themeJson document;
QbzTheme rebound to it so the whole app repaints live (OLED->Light
probe-verified). Full subnav (8 sections + Share logs footer);
Appearance 1:1 (theme dropdown with dark/light filter, ambient/titlebar
rows in their real home, typography, library visuals, window chrome,
tray via tray_settings.db, renderer); Integrations 1:1 (MusicBrainz,
Last.fm 2-step browser connect + authed state, ListenBrainz token
validate, Discord toggle -> live DiscordRpc.set_enabled). Offline/Local
Library/Blacklist/Developer render honest placeholders.

km
2026-07-28 06:13:04 -06:00
vicrodh 9f86a40f52 feat(qbz-qt): phase 14 — app-wide ambient background (modo Cider)
Port of the spec's palette derivation (16x16 downscale, coverage-
weighted 24-bin hue histogram, genuine secondary cluster or deeper
same-hue, +180 accent, B/W defaults) driven by the now-playing artwork
on track change. QPainter AmbientField with the WGSL scene's exact
four blob orbits, dim scrim, 30fps cap paused when inactive; chrome
surfaces translucent per the AppShell alpha tokens (content frame
surface-main @ 0.22 frosted). Same shared app_background pref (owner
has it 'ambient'), app-menu toggle applying live, QBZ_BG_* env knobs.
4 new unit tests (14/14 total).

km
2026-07-27 23:37:13 -06:00
vicrodh d57cec974f feat(qbz-qt): phase 10 — Settings > Audio and Playback 1:1
settings_qt.rs controller over the shared stores (AudioSettingsState,
PlaybackPreferencesState, ui_prefs.json, qconnect_settings.db) with the
upstream cascades (dac-passthrough->skip-sink-switch, streaming-only->
gapless, backend switch resets routing) and live apply through public
player APIs (reload_settings/reinit_device/clear_audio_cache). ALSA
device regrouping (Defaults/Bit-perfect/Plugin/Other + BP badges),
searchable QbzSelect, QbzToggle/QbzSlider/SettingRow replicas, exact
row order and gating. Persistence probe verified (gapless +
streaming-quality round-trip, original values restored).

km
2026-07-27 19:12:06 -06:00
vicrodh b10ff98620 feat(qbz-qt): phase 9 — Queue homologation + Lyrics side panel
Queue panel 1:1: History tab (click replays per queue.rs semantics),
#442 'Next in queue'/'Next up' section markers from manual_next_count,
40-row pagination + live search + page clamp, row actions (play,
remove, remove-all-after, heart), press-drag reorder via move_track,
clear queue, plural-aware footer count, exact empty states. New Lyrics
panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain
with LRCLIB fallback, synced line view with line-level highlight +
centered auto-follow + click-to-seek, exact status bodies. Both panels
stack Feishin-style in the right column; NPB lyrics button wired.

km
2026-07-27 18:22:22 -06:00
vicrodh 1b81b289d1 feat(qbz-qt): phase 5 — Library view 1:1 with All grid/list and perf marks
Six live tabs (All/Tracks/Albums/Artists/Playlists/Labels) with real
count badges, paged favorites/playlists/purchases loaders, library_all
merge semantics, heart routing (Qobuz API vs local store —
LocalFavoritesService now initialized at session entry), sort popup
with direction carets, grid/list toggle with natively windowed
GridView/ListView delegates, id-keyed artwork via signal with hand-
rolled eviction. Perf marks on the owner's account: 344 items, fetch
wall 2.7-3.4s (97% network, sequential), map+merge ~11ms, JSON
serialize 10ms, QML parse 7ms (~20us/item => ~140ms at the 2200-item
reference scale), RSS +29MB after publish.

km
2026-07-27 14:57:29 -06:00
vicrodh 95df643a25 feat(qbz-qt): phase 3 — Discover > Home with live Qobuz data and artwork
Pure mapping port of home.rs/foryou.rs (quality tiers, award ribbons,
slim grids) into Slint-free rows; discover index + favorites +
release-watch + top artists fetched concurrently (12 sections, ~170
cards live-verified). Artwork via the shared qbz-cache ImageCacheService
(semaphore-16 downloads, file:// rows, native QML decode). HomeView.qml:
tab pill, five rail kinds, shimmer skeleton, error+Retry, offline
placeholder — exact msgids throughout. Sections travel as one JSON
document (cxx-qt-lib cannot nest QVariant containers — documented).

km
2026-07-27 13:36:01 -06:00
vicrodh df7eb3588e feat(qbz-qt): phase 1 — login screen with full offline guardrails
QbzBridge QML singleton (12 properties, 8 invokables) driven by tokio
tasks via CxxQtThread. Port of auth.rs (browser OAuth loopback listener
with qbzd-style nonce, 180s timeout, cancel via JoinHandle abort,
scoped offline-gate lift for cold bundle init, D1 token-keep on network
failure / clear on explicit rejection, D4 subscription grace marks).
OfflineModeEngine + ConnectivityActor started at boot with live watch
forwarding (connectivity/captive portal/recovery banner/has-previous-
session). Boot: splash -> silent token restore -> shell | login.
Start-offline enters the guest profile. LoginScreen.qml is a faithful
port with exact msgids routed through qbz-i18n. 5 unit tests green.

km
2026-07-27 12:09:31 -06:00
vicrodh 469f83781d feat(qbz-qt): phase 0 — crate skeleton + cxx-qt toolchain gate
New qbz-qt bin crate (cxx-qt 0.7 + QML) as the only default workspace
member in this worktree, so plain cargo build produces just the Qt
binary. Gate verified against system Qt 6.11.1 (codegen, moc,
qmltyperegistrar, qrc load, offscreen run all green).

km
2026-07-27 11:36:18 -06:00