Commit Graph
20 Commits
Author SHA1 Message Date
vicrodh d555b8fde4 feat(qt): add album quick view and queue focus controls
cx
2026-08-26 16:29:16 -06:00
vicrodh 390a800545 fix(ui): cap now playing cards at eighty percent
Apply one shared runway fraction to the SongCard in New, Classic, Large, and Small modes while preserving each mode's alignment.

cx
2026-08-23 20:36:52 -06:00
vicrodh 48c6f6854d fix(qt): harden remote playback transitions
cx
2026-08-22 15:42:06 -06:00
vicrodh c6d142ccb4 perf(qt): virtualize discovery and warm playback
cx
2026-08-22 14:35:36 -06:00
vicrodh 58fa77a390 feat(qt): fade artwork in as it arrives
Artwork appeared in one step the instant its pixels were ready, which
reads as a glitch on a page where covers land a few at a time.

It belongs in this file and nowhere else: AlbumCard, PlaylistCard,
TrackCard, ArtistCard, SlimCard, LabelCard and RadioCard all mount a
RoundedImage, and 81 files in the tree do. Per-card animations would be
seven copies that drift, and would still miss the detail headers and the
rows.

The trigger is `ready`, not the path and not `status`. `ready` is this
file's readiness contract, MONOTONE WITHIN ONE SOURCE, which is exactly
what a fade needs: the derivative swap sends `status` back to Loading
mid-life, and fading again there would flash a cover already on screen.
A recycled delegate does re-fade, which is correct — it is a different
cover. The Component.onCompleted arm is not belt-and-braces: an
already-cached pixmap reaches Image.Ready DURING creation, before
onReadyChanged is connected, and without it that cover would sit at
opacity 0 forever.

Measured against the repaint doctrine rather than argued:

  at rest              0 presents/s (byte-identical frames over 3 s)
  during a page load   Home 5.7 -> 6.0/s, Library 11.1 -> 12.4/s

Qt advances every running animation on one tick and the window presents
once per frame however many items are dirty, so fifty covers arriving
together cost the same presents as one. OpacityAnimator, not a Behavior,
so the fade keeps advancing while the GUI thread is still paying off a
mount — which is precisely when covers land.

cc
2026-08-21 23:59:47 -06:00
vicrodh b136c9cf5d feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).

The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.

km
2026-08-13 13:01:32 -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 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
2026-07-29 21:23:10 -06:00
vicrodh 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
2026-07-29 16:25:57 -06:00
vicrodh 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
2026-07-29 16:25:42 -06:00
vicrodh 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
2026-07-29 10:30:43 -06:00
vicrodh c56fab3c4e fix(qbz-qt): covers were never actually resized, plus the adversarial parity round
**The scaled-cover fix from the last round produced nothing.** The seam ran —
462 requests in one offscreen smoke — and wrote zero files, so the owner was
right that nothing improved. The artwork cache stores files as `.img`, and
`image::open` picks its decoder from the EXTENSION, so every call failed
silently. Sniffing the content instead yields 101 derivatives on the same run.
The header atmosphere had the identical bug and had therefore never produced an
image either, which is why the flat fallback was all anyone ever saw.

Reported as fixed last round without checking that it ran. Verified by effect
this time, not by reading the diff.

The rest came through an analyse / refute / implement / verify pipeline, and the
adversarial passes earned their keep:

- The sidebar mini rail: icons sat left of centre because their slot kept its
  fixed 24px width while the rail collapsed, so the hit target was rectangular
  and off-axis. The rail narrows, rows are square, and the mini state gains the
  folder flyout and tooltips it never had. The reviewer caught a comment
  claiming the width change was a one-token revert when it is two — corrected
  rather than left to mislead the next reader.
- Playback context now travels from every producer, not the handful that
  happened to stamp it.
- Collapsible per-tab search in Library, and label artwork.
- Every View-all destination Home offers, plus the label page and its releases.
- Collapsing the sidebar drops a Large bar back to New, live, without
  overwriting the user's persisted preference.

cc
2026-07-29 00:06:34 -06:00
vicrodh 2a5b5ac16e feat(qbz-qt): theme-following icons, the real header atmosphere, sharp covers, playback context
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light
themes secondary, muted and accent were simply wrong, and accent was wrong on
any theme whose accent is not #4285f4. Slint recolours at render time, which
this path cannot do — so the tint is generated once per colour into the cache
and loaded through the same Image+svg path the bakes already use. No effect, no
shader, so it cannot hit the "renders nothing on software" failure.

The sweep found the part the diagnosis had missed: "primary" was two meanings
welded together — max-contrast-on-theme-surface, and literal white for glyphs on
hosts that are dark under every theme. Remapping it globally would have painted
dark glyphs on dark scrims in fourteen places. The vocabulary is split instead,
with "primary" left as an alias so nothing changes until it is migrated
deliberately.

**The album header gradient was Slint's fallback, not its design.** Slint paints
a blurred artwork field and only falls back to the flat two-band gradient when
that image is unavailable; the port shipped the fallback. The field needs no
shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down
to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look,
not an optimization; everything after it works from eight pixels of colour,
which is why it reads as atmosphere rather than as a blurred cover. It comes out
of the same decode that already produced the flat tint.

**Covers were point-sampled.** Canvas drawImage does not filter when it scales,
measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in
a 200px cell throws away eight of every nine pixels, and the error is aliasing,
not softness. Rust now produces a derivative at the drawn device size so the
draw is 1:1 and there is no resample at all.

**The now-playing context stack.** It had no Qt counterpart: the model carried
context_kind and context_id, some builders stamped them, and nothing ever read
them back. The album path only appeared to work by coincidence, because the
glyph was hardcoded to open the album.

Plus: Discover's missing rails with real play history, Recommendations on the
integrations, queue drag reorder, and the playlist detail collage.

cc
2026-07-28 21:12:31 -06:00
vicrodh a1c771c39e fix(qbz-qt): row buttons, cover triggers, light-theme contrast, flyouts, genre filter
**Track-row buttons did nothing.** Declaration order is z-order in QML, and the
row-wide MouseArea added for right-click was declared last — so it covered the
heart, cloud and menu buttons and swallowed their presses.
propagateComposedEvents cannot rescue that: a press is not a composed event,
which is why only the row's own double-click still worked.

**Covers appeared only after a scroll.** Three compounding defects, not one. The
window report fired on contentY and on model change, both of which happen while
the tab body is still hidden behind its loading flag, so the report that
mattered hit the not-visible early return and was discarded with nothing to
re-fire it. The debounce reset on every change, so a burst of mount-time reports
kept pushing its trailing edge out. And a single pending slot meant that when
two surfaces are mounted at once — the artist rail and that artist's grid — the
second overwrote the first, so one pane was never requested at all. Eviction had
the same shape: each report deleted everything outside its own array, so the two
panes deleted each other's covers.

**Latest release had no cover** because the artist page's collector skipped it,
along with Appears On and the playlists strip.

**Light themes.** The mechanism is Slint's, from 2.0.2 and issue #638: the
on-surface arm on circle actions, header-light deciding the header foreground
only when a dark backdrop is actually painted, and the circular backing behind
the track-row play glyph. Ported as specified rather than approximated. Two
icons rendered in their SVG's own colour because they pointed at the raw asset
instead of a baked tint — a sweep found the rest of that class.

**Navigation flyouts** exist, one component serving both the sidebar and the
header nav modes.

**Filter by genre and the Discover sort now work**, everywhere the control is
drawn. The genre ids were already a parameter on the discover fetch that the
port always passed empty.

cc
2026-07-28 19:55:33 -06:00
vicrodh 9ab3d7db96 fix(qbz-qt): local albums route to the local view, and the detail-page parity round
**The empty album view.** Opening a local album from Library "All" landed on a
blank page. The mixed feed hands Qobuz and local albums to the SAME card, and
the card always called the catalog open — so a local id, which is a group key
or a filesystem path, went to /album/get and came back 404. It was in the
owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what
changed is that clearing the stale document turned "shows the previous album"
into "shows nothing", which is how it surfaced. Routing now happens in
open_album itself, using the predicate the feed already uses to route
favourites, so every call site is covered at once instead of card by card.

**Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted
from THIS page's artwork through the ported header_tint — not the now-playing
ambient triad, which is a different subject with a different algorithm.

**Artist page.** The catalog/library toggle existed but was broken: a
hand-rolled copy of the shared tab bar whose delegate walked the wrong parent
chain, so it threw on every evaluation and never took its active colours. It
now mounts the shared control. Added the JUMP TO label, the tab-strip search
with the client-side filter it drives, and the year under release-section
albums. The Network panel ran the full window height and never auto-opened; it
is bounded below the tab strip now and opens unless the content is constrained,
as Slint does. The action circles were the wrong size with the wrong glyph
colour and no overlay arm — which would have left them dark-on-dark once the
gradient landed.

**Quality in track rows.** The column rendered a bare tier; it carries the
badge now, in the one shared row component every surface mounts. playlist_qt
was the only producer not emitting the bit-depth line.

**Skeletons hand over instead of disappearing.** They cleared when the PATH
arrived, but the image still had to load and rasterize after that, so the
placeholder vanished and left an empty cell. They now wait for the image to be
ready.

**Local covers.** Most local covers ARE already the scanner's thumbnail, but
re-hashing their path produced a different filename, so each one was decoded,
resized to its own size and re-encoded into a duplicate before being shown.
That was most of the wait.

cc
2026-07-28 19:01:25 -06:00
vicrodh b580fdafad fix(qbz-qt): rasterize every Canvas on the GUI thread
The owner hit a segfault after clicking around. Cooperative and Threaded
rasterize on the scene-graph RENDER thread, and RoundedImage is instantiated in
25 files — every card, row and queue row — so list recycling creates and
destroys hundreds of these while async artwork callbacks schedule repaints on
them. The render thread can be mid-draw on a pixmap whose Canvas is already
gone. The ambient field makes it worse by repainting 30 times a second for the
whole window lifetime on that same thread.

Immediate keeps paint and destruction on one thread. renderTarget: Image
already meant CPU raster, so nothing looks different.

Not yet confirmed on hardware: the decisive check is running the previous
binary with QSG_RENDER_LOOP=basic, which gives Cooperative no separate thread
to race with. If that survives the repro and the default loop does not, this is
the cause.

cc
2026-07-28 16:22:19 -06:00
vicrodh 71c2356f7b feat(qbz-qt): port the quality badge 1:1 from the Tauri original
The port had reinvented this badge: an icon-only replica with no MP3 tier, no
full or compact form, and none of the playback states. QualityBadge.svelte and
QualityBadgeStatic.svelte in the archived Tauri tree are the reference, and
every number here comes from their CSS.

Four modes, the union of both originals: full (the 136x36 pill with tier row
and bit-rate), compact (the narrow-bar "24/96" form from issue #303), bare
(full's content without the pill chrome, for toolbars) and iconOnly (the
Hi-Res brand mark alone, or a framed 30px square for CD/MP3 so both line up in
a grid). Tier resolution follows the original's order, MP3 first so it can
never come out as CD.

The four playback states come with it: system resampling, ALSA plughw
fallback, deliberate quality adjustment and confirmed bit-perfect, each with
its own border hue and indicator glyph, plus the rich hover tooltip carrying
source, output, the reason and the fix. Rows that only advertise stored
quality leave the flags false, which is exactly what QualityBadgeStatic was.

QualityMini becomes an alias over iconOnly mode rather than a second drawing
of the same thing, so the six existing call sites keep working while there is
one badge again.

Brings the assets the port was missing: cd.svg, mp3.svg and the LINE Seed JP
face the badge is set in. Their tint is baked to #b3b3b3 because Tauri applied
it with a CSS filter and QML has none on the software path. Adds theme.alphaTier(pct)
so the badge names the alpha ramp the design specifies instead of a raw index.

build.rs now collects the whole asset tree recursively — the hand-written root
list meant a new asset compiled clean and failed at runtime with "Cannot open".

18/18 tests; offscreen smoke clean.

cc
2026-07-28 12:49:50 -06:00
vicrodh 70bd337d16 feat(qbz-qt): Large NPB dock — sidebar space reservation + FFT spectrum band
The Large dock was a bare cover overlay: the sidebar knew nothing about it,
so the playlist rows kept running to the sidebar's bottom edge and sat
underneath the album art. Sidebar.slint reserves the in-sidebar portion of
the dock; the port had dropped that.

Reserve it the same way, from a single source of truth. QbzShell gains
largeDockHeight (shell_bridge.rs owns the arithmetic: 9 top + band + gap +
208 art + 4 bottom), the sidebar subtracts largeDockHeight - npbLargeHeight
from the playlist list, and AppShell pins the overlay at height -
largeDockHeight. The 112px bar height becomes a theme token so the shell and
the reservation cannot drift apart.

The band the cover was missing needs the FFT tap, so wire it: the runtime is
built with_visualizer, viz_qt.rs runs the frontend-agnostic producer from
qbz-audio and drains it at 30fps into a new QbzViz domain bridge as typed
QList<f32>. Bars (28 mirrored columns over the 14 active bins), Waveform (48
downsampled L columns) and Energy (5 bands) render as Slint does, the cover's
eye button toggles the band, clicking it cycles the mode, and both persist to
ui_prefs.

Cost is zero when unused: the tap starts disabled, the eye toggle gates
capture, the drain thread parks while off, and the transport is mirrored so a
paused player parks the producer instead of re-analyzing a stale buffer. The
QML binds bar HEIGHTS against static-model Repeaters — the lists are replaced
per frame, so a model binding would rebuild every delegate 30 times a second.

Adds the eye/eye-off icon pair to the seven baked tint variants.

18/18 tests; offscreen smoke clean (0 QML errors, home published).

cc
2026-07-28 12:05:37 -06:00
vicrodh 9a4fec6b70 refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges
Two fronts of the modularization, both gated green before this commit.

QML leaves the flat qml/ directory for seven modules: cards, controls,
rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml
stay at the root. build.rs matches the tree exactly (55 files), relative
imports and asset paths were re-anchored for the new depth.

The QbzBridge God-object starts breaking up: QbzSession, QbzShell,
QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge]
singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted
in order from Main.qml. Bridge files stay directly under src/ because
cxx-qt-build only accepts rust_files from one directory (QTBUG-93443).

bridge.rs keeps 5.5 domains (library, album/artist, settings,
integrations, search, playlist) and dies once they are split out.

cc
2026-07-28 11:08:20 -06:00