Commit Graph
56 Commits
Author SHA1 Message Date
vicrodh 415cba57de feat(qt): custom title bar on Windows, with a native hit test
Three requirements -- no title bar, working clicks, and window tiling -- and no
combination of public Qt flags delivers all three. Each attempt was measured on
the box rather than reasoned about, and each one taught something the plan had
wrong.

FramelessWindowHint, the previous arm, removes WS_THICKFRAME. THAT is what the
shell keys tiling on -- not WS_CAPTION, which this window never has in any
arrangement -- so Win+Arrow and Aero Snap were dead, along with the DWM shadow
and the Win11 rounded corners. "Hide title bar" was not making the app refuse
the shortcuts; it had stopped being a window the shell could tile.

Adding CustomizeWindowHint restored tiling and stopped Qt painting its own
minimise/maximise/close cluster over the QBZ header. It also made Qt answer
WM_NCHITTEST with HTNOWHERE for EVERY pixel -- centre, header, player bar and
sidebar all returned 0 -- and Windows delivers no mouse input to HTNOWHERE, so
the whole app became unclickable while still running: the tray menu still drove
playback. Dropping the hint fixed clicks and brought the drawn buttons back,
duplicated on top of the ones QBZ already draws itself.

So the hint stays and cxx/win_shell.cpp answers the hit test instead. The
filter defers to DefWindowProcW, which is what an ordinary sizable caption-less
window would return: the eight edge codes near the frame, HTCLIENT everywhere
else. It invents no geometry. Its guard is narrow on purpose -- sizable AND
caption-less, which is exactly the custom-chrome main window -- so menus,
tooltips, popups and any window showing the system title bar keep Qt's own
answer.

Measured after: HTCLIENT at the centre, in the title band, at the edges and in
the corner; Win+Left resized the window to 1720 px, exactly half of a 3440 px
display.

Dragging is unaffected and stays with QML's startSystemMove(): with no caption
there is no HTCAPTION band to inherit, which is the arrangement HeaderBar was
already written for.

NOT restored, and not restorable from flags: the Win11 Snap Layouts hover
flyout. Windows asks for it by hit-testing the maximise button as HTMAXBUTTON,
and QBZ's is a QML item inside the client area. Reporting HTMAXBUTTON over that
one rect from this filter would do it -- named as future work rather than
claimed.

cc
2026-08-27 16:52:17 -06:00
vicrodh fc0d35e295 fix(qt): platform capability flags, and hide the audio rows nothing can honour
Adds isWindows, traySupported, systemNotificationsSupported and
rendererSelectable to the settings document, so Settings QML keeps reading
ONE document the way it already does for isMacos. The two any(...) lists
flip to include Windows when plan A-2 T3/T5 land; they point at that line.

Then the Audio tab, measured on the box with the tab open:

HiFi Wizard was live on Windows. qbz-dac-wizard-core generates a
PipeWire/WirePlumber config and knows no other stack, so the wizard cannot
finish anywhere but Linux. Hidden, not disabled - a disabled row invites a
bug report. This is not the reference's backend-is-pipewire gate returning:
inside Linux every backend still sees it, per the 2026-08-11 ruling in that
file's own comment.

Reserve DAC is D-Bus org.freedesktop.ReserveDevice1, Linux only. On Windows
exclusive mode IS the reservation; on macOS it is Hog Mode.

Exclusive mode was visible with no backend able to honour it. It now follows
the backend: Linux unchanged (always visible, enabled only on ALSA), hidden
on macOS, and on Windows it appears when Phase B publishes backendIsWasapi.
QML reads an absent doc property as undefined and undefined === true is
false, so this is correct today and correct again the day the field lands.
The DSD row follows the same pair.

Main.qml no longer boots the wizard off Linux, where its row is hidden and
the audio-stack probe would reach nothing.

Linux sees no change: every gate is isLinux-true or an added || on a field
that is undefined there. Five QML audits pass.

cc
2026-08-27 04:01:13 -06:00
vicrodh edcb12b8a1 feat(qt): close migration parity gaps
cx
2026-08-26 11:11:50 -06:00
vicrodh 82953c72da fix(qbz-qt): honor system font with script fallback
cx
2026-08-25 20:15:38 -06:00
vicrodh 0e53837582 feat(metadata): restore local tag editor in Qt
Restore the selected-version editor with sidecar-safe defaults, inspected tag layers, guarded MusicBrainz and Discogs lookup, and confirmed verified direct writes. Keep paths Rust-owned and reject stale or injected drafts.

cx
2026-08-24 16:41:06 -06:00
vicrodh 5160ddf6c1 fix(player): make song info responsive
cx
2026-08-22 22:26:32 -06:00
vicrodh cf07f10a59 fix(settings): make the Font row actually change the font
It shipped doing nothing. Measured on the built binary: picking a face
persisted the pref and changed ZERO of 600600 pixels in the settings
label column. The row was a control that lied.

Two measurements say why, and both were run against this Qt build rather
than reasoned about:

  - `ApplicationWindow.font.family` propagates through Qt Quick CONTROLS
    only. In a standalone scene with a family set on the window, a
    `Label` resolved to it and a sibling `Text` stayed on the
    APPLICATION font. This tree is 1082 plain `Text` items against 13
    that name a family, so the app's typeface IS the application font.
  - The application font is read when an item is CONSTRUCTED. Driving
    PySide6 against the same Qt, `QGuiApplication::setFont` updated
    `QGuiApplication::font()` and left an already-built `Text` on its old
    family.

Together those close the door on changing it live, so the ordering in
main() is the feature: FontPreload.qml registers the bundled faces (a
family does not exist until its file is loaded, and `load()` returns
with its FontLoaders already in the database), the application font is
set from the persisted choice, and only then does Main.qml build the UI.
Swap any two of those and the setting silently does nothing again.

The row now says "(requires restart)", the same wording Interface size
two rows above already uses, in all eight catalogues.

"System" resolves to "" and takes no branch, so anyone who never opens
the row gets exactly the font this app rendered before — verified by
capturing the window on both settings: 0 log lines and an untouched
frame for System, 27065 changed pixels for Montserrat.

NOT ADDRESSED, and worth its own decision: those 1082 Text items have
never used the bundled Inter either. `Main.qml` has always named it, but
that only ever reached the Controls, so the app renders in whatever Qt
picks. Making "System" mean Inter is a one-line change and a visible
one, so it is left alone here.

cc
2026-08-22 01:31:42 -06:00
vicrodh 3c4c2dae20 feat(settings): an app-wide typeface, picked from the fonts already shipped
Settings > Appearance > Typography & Language gains a Font row. The
catalogue is the SAME five the lyrics panel offers and the same slugs,
because they are the same files: everything is bundled in
qml/assets/fonts/, so a choice cannot fail on a machine that lacks the
face and looks identical everywhere. "System" resolves to the window
default, which is Inter.

It applies LIVE, with no restart: the family sits on the
ApplicationWindow, which every Text that does not name its own family
inherits.

The index rides its own qproperty rather than the settings document,
because the consumer is the root window and making it parse the whole
settingsJson on every republish to read one integer is the wrong trade.
Same shape as theme_filter beside it.

The loaders are lazy, exactly like the lyrics picker: a family nobody
selected is never loaded, and one that has not finished loading falls
back to Inter rather than rendering the app in a wrong face for a frame.

Only the first label is translated — a typeface name is a proper noun.

cc
2026-08-22 00:00:05 -06:00
vicrodh 2273db9184 feat(qt): the disc surfaces — rip wizard, metadata correction, and a pane that
can be read

RIP WIZARD, three steps, each answering something the first version got wrong
by not asking. What will happen — which disc, how many tracks, and that the
output is FLAC, stated rather than implied. The tracks, editable, because a
CD-DA carries no titles at all so "named wrong" is the ordinary case and once
files are on disk it is a rename job. And what happens afterwards: a ripped
album the library cannot see is a folder full of files, so the destination is
compared against the registered folders — by path COMPONENTS, not by string
prefix, since /music-backup starts with /music and is not inside it — and the
right follow-up is offered, naming the folder it would re-scan. "Do nothing" is
an explicit choice rather than the silent default. The follow-up runs only on
success.

Tracks can be deselected. Everything is ticked on arrival and the user goes
from all to fewer; a wizard that starts empty makes the common case the one
that costs the most clicks. On the Rust side an absent `selected` means yes, so
an older form cannot silently rip nothing.

A PROGRESS PANEL behind the ripping chip, which is a door rather than a label:
it is the only thing on screen during a rip and the two questions it could not
answer are the ones that matter. A soft lock says not to eject — soft because
nothing here can hold a tray shut, so the honest move is to say it and keep
saying it. Per-track state with a bar on the row that is actually moving.
Stopping is offered, deletes nothing, and says so; a cancelled job does not
jump to 100 %, which is the one lie a progress bar must not tell.

CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder,
which has no identity to write a correction under. The list shows what
distinguishes four pressings of one album rather than four identical titles,
and flags a track count that disagrees with the disc. Selecting is not
applying: the candidate is fetched in full and its track list shown, because
that list is the only thing proving a plausible title is the right pressing.
Applying renames the live session without renumbering ids — a re-open would
orphan a playing track — and writes a user row. Only MusicBrainz re-resolves
the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is
worse than the old one. The correction can be dropped, because without an
escape a bad apply is permanent and that makes people afraid of the button.

THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track
set whose album_group_key is the box root for every file while the disc number
sits in the data unused — so the pane was a flat list 247 rows long. The key
carries the disc, unconditionally, because it round-trips through the play-album
lookup. Its own client-side filter over what is already rendered, since finding
one track meant scrolling past the other 246.

EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published
isEphemeral on every row for a while and the queue panel respected it; the bar
never read it. The heart goes inert, the add menu keeps only its queue actions,
and the row menu comes back with those three and nothing else. Absent rather
than rendered-and-inert.

Also: a spinner on Open that blocks a second click onto a drive already being
read, held by a counting RAII guard because opening is a relay and the chip
must not blink off in the seam; the now-playing card routes a disc to the pane
it came from instead of to an album id no catalogue can open; the file pickers
use rfd, the platform's own dialog, which the POC-NOTE said was a five-line
change once the dependency existed — it existed, Settings had been calling it
from this crate for weeks, and on macOS the four Linux choosers do not exist so
Open folder and Open SACD image opened nothing at all, in silence; the empty
state stands down for the Open pane, which on a machine with no library drew
both fully painted; tooltips on every disc control through one hover() helper,
since two call sites had already mismatched their key, which does not fail —
the bubble simply never goes away.

cc
2026-08-21 18:58:22 -06:00
vicrodh 26ceeab828 feat(qt): mixed playlists, page fade, category filter, per-context recents
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.

MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.

Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.

PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.

The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.

SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.

CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.

APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.

RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.

ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.

Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.

Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.

Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.

cc
2026-08-19 07:00:35 -06:00
vicrodh b2c74c4140 feat(qt): find an available version for a track Qobuz pulled
Reachable from a dead row's context menu, in a playlist the user owns. The
reference had this and shipped it with four defects; none are ported.

It was unranked -- Qobuz's raw relevance order shown to the human. The weighted
matcher that already lives in qbz-playlist-import does the ranking now, and its
ISRC short-circuit matters most: licensing churn usually republishes the SAME
recording under a new id with the SAME ISRC, and the pulled track keeps its
isrc (confirmed in the 2026-08-17 capture of album 0886443985094). That case
needs no human judgement, so the ISRC lookup runs first and pins its hit to the
top.

It removed before it added, so a failed add lost the track outright. The order
here is add, reposition, remove.

It did not preserve position -- the computed index went to a console.log and
the replacement landed at the end. It lands where the dead row was, through the
new updateTracksPosition verb. Every failure in that step is a warning and a
different success toast: the repair still stands at the end of the playlist,
exactly where the reference always put it. Position is a nicety, never a reason
to abort a repair.

Two guards. If the ISRC lookup answers with the dead track itself -- it can,
since a pulled track keeps its metadata and search still indexes it -- then
"replace" would be a no_duplicate no-op followed by a remove, and the user
would lose the row while being told it was repaired. And when the remove fails
the playlist permanently holds both rows: that is not rolled back, because a
rollback is a second write that can fail the same way and would delete the only
good copy. The list is refreshed so the user SEES both, and the toast says the
old one has to go by hand rather than claiming a reload will fix it.

cc
2026-08-17 14:41:39 -06:00
vicrodh dc2496e5c0 feat(qt): award pages, reachable from the album sidebar and from each other
Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine
line, follow heart, "Acclaimed Releases" with its count and See All, the
preview grid and the "Other awards" rail) and the paginated listing with
its client-side search. Routes "award" and "awardalbums", the label pair's
shape — one controller for both, since they share the hero, the follow
state and the identity.

The three rules from the Tauri review are in the module header because they
are the ones a re-implementation gets wrong: the grid is ALWAYS
/award/getAlbums (/award/page is user-scoped and may omit releases, so it
feeds the hero and nothing else); the favorite split is plural-read,
singular-write, because the backend derives `award_ids` from the write
kind; and getAlbums has no has_more, its `total` is a hint, so the only
sound predicate is `total > loaded`.

The album sidebar's AWARDS cards navigate now — with an id directly, and by
NAME through the resolver when Qobuz omits it, which it does. They were
also reading `album.awards` when the field lives on `album.header`, so the
block had rendered EMPTY for every album since it was written: an empty
Repeater draws nothing and reports nothing, and the sidebar's own gate
counted the same empty list. The LABEL card beside it had a "no label view
yet" note and no handler, years after the label view landed.

Beyond the reference: an all-awards dropdown pinned to the page's top-right,
so any award is reachable without first finding an album that won it. It
costs nothing new — the /award/explore crawl already existed to resolve
names to ids.

The award icon was lucide's MICROPHONE under the wrong filename. Search had
routed around it with `crown` and left a note; kiosk was missed and drew a
mic for "Most popular". The asset is the laurel the other two frontends use,
and kiosk now asks for the crown it wanted.

cc
2026-08-16 18:56:20 -06:00
vicrodh 7d98ad044f feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON
document per screen plus a revision counter, so QML re-parses even when the text
is byte-identical.

- The list: two tabs with per-type counters, a server-side search on a 300 ms
  debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort,
  direction, two filters and grid/list; Tracks gets group and the filters only.
  Building the Albums toolbar on both would have invented two controls.
- The detail: a format dropdown whose selection re-scopes every downloaded mark,
  the progress section and Add-to-Library together; a determinate bar counted in
  TRACKS, never bytes; goodies as an album-level action counted separately.
- The album download is the frontend loop, not the Rust command the reference
  registered and never called. Sequential, concurrency 1, cancellable, and a
  failed track does not abort the album.
- Both prefs are read at runtime, not merely written. `show_purchases` is the
  master gate and `nav_tb_purchases` only relocates the entry to the title bar;
  both default false, so Purchases still ships hidden.
- Scanned local rows keep their raw `qobuz_purchase` source so the gold badge
  survives in list mode as well as grid, while still classifying under the
  Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping
  them would badge every purchase the user merely OWNS as locally downloaded.

Defects fixed after the screens were written, none of which any test could have
caught:

- `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every
  publish is a silent no-op, so both screens would have stayed permanently empty
  with nothing logged. The cause was an ownership gap rather than the code:
  `Main.qml` belonged to no lane because the plan listed the files each lane
  CREATES rather than the files it TOUCHES, and "belongs to nobody" is
  indistinguishable from "done" until something runs.
- A second download of the same album nested inside the first. The store rewrites
  its destination to the album subfolder after the first successful track so that
  Add-to-Library adds the album rather than the whole music root, and the download
  planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`,
  one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are
  now separate fields with names that cannot be confused, with a regression test.
- A tab counter could stick at zero forever with its list plainly full: the two
  ids calls each swallow their error, and the reference's backfill from the list
  response was missing.
- Cancelling made the whole progress block vanish instead of showing the
  cancelled state, whose msgid was already translated in all eight catalogs.
- Add-to-Library re-walked the entire library when the folder id it needed was
  already in hand.
- A live language switch and a logout left the previous state behind.

Deliberate divergences from the contract, each recorded with its reason: the page
header is a plain `fontSection` title with no icon tile, because nothing else in
this application announces itself over a large coloured square; purchase download
status travels inside the album document rather than on the shared cache channel,
which is keyed by track id alone and would let the two features overwrite each
other on the same row.

Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p
qbz-qt` 398 passing, offscreen smoke green with zero QML complaints.

Not verified, and cannot be from here: every populated screen. This account owns
nothing, so only the empty states have ever rendered.

cc
2026-08-16 11:54:05 -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 8bd02e35d3 feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.

ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
  no onClicked, and a RoundedImage with no fit:, so the default crop blew an
  800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
  2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
  surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
  ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
  reference already has (Add/Change/Remove image, Open in browser, Save as…) and
  the lightbox this port invented for albums and playlists. Custom images are
  keyed by artist NAME into the same custom_artwork.json the Slint build writes,
  so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
  covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
  artwork_qt::cached_path consults the override FIRST, so coverMap held the
  custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
  is not: it leaves the percent-escapes in place, so any path holding % # or ?
  came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
  "View cover", since en carries none of that family and falls back to the msgid.

CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
  AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
  their expansion into the full 55-value palette and the on-disk format all stay
  in the shared qbz-theme crate, so a theme authored in the shipping Slint build
  already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
  and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
  because current_slug() re-reads it from disk — a seed taken afterwards would
  resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
  memoized for the life of the process and filled at bridge construction, so a
  process that booted without the file would keep serving the OLED default even
  after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
  every drag frame with a truncating write, on a file the other build has open.

ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
  from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
  a section plus a TOC entry, bold and backticks are stripped rather than styled.
  Text.MarkdownText is a different subset and would have rendered a different
  document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
  to bump.

DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
  carries about seventy, so every issue filed from a Qt build arrived with no
  kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
  sample-rate state and no playback context. The report is injected INTO
  qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
  makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
  not mapped, so their runtime column can only read "—", and their saved column
  would come from a graphics.json this binary never reads or writes — foreign
  state presented as this app's configuration.

SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
  on declaration order and carried no z, so any z-bearing modal buried them: a
  toast raised by a background event behind an opaque panel is never seen. They
  now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
  worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
  always-dirty, so the whole build script — per-shader qsb, the recursive asset
  sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.

Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.

Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/

cc
2026-08-15 00:52:25 -06:00
vicrodh 016eb3cd23 feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.

- qbz-core gains scene discovery ("artists from the same place"): a
  cancellable run with a phase for the progress label and a typed
  SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
  an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
  are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
  and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
  bridges. The five-branch musician dispatcher is wired to all six of its
  consumers. Both scene doors are live: the sidebar location link and the
  overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.

Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:

- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
  the engine; now_playing::set_volume has two callers and neither runs in a
  local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
  while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
  audio data available") for want of a has_loaded_audio() guard. Investigation
  in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.

Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.

cc
2026-08-14 19:25:11 -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 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 a64c20b09f fix(qt): never let the shutdown path outlive the UI, and log the tray hop
An owner smoke reported no way to close the app but killing the process.
That was not reproduced here — driven through D-Bus exactly as a panel does
it, the tray's Quit exits in 0.4s and the app-menu Close with close-to-tray
OFF exits in 0.5s, both code 0 — so this is insurance and instrumentation,
not a claimed cure.

The insurance: once the event loop is gone the process has no UI, so nothing
on that path may block without a bound. The cast shutdown ran unbounded and
takes a tokio Mutex the cast poll task also holds, aborted only AFTER the
lock is acquired. Two seconds is the cap; a cast device that keeps playing
that much longer beats an app that cannot be closed.

The instrumentation: tray_qt logs when it SENDS, and nothing logged on the
QML side, so "the tray does nothing" could not be told apart from "the
signal arrived and QML ignored it". The three handlers now say which. That
ambiguity cost a diagnosis today; one line each ends it.

cc
2026-08-04 08:42:03 -06:00
vicrodh 088387f2c1 feat(qt): close-to-tray across every exit, and the macOS tray settings rows
Block B6 of the miniplayer/tray contract. B5 landed a tray whose clicks
reached Rust and stopped there; this is the other half.

qml/Main.qml — hideToTray(), showFromTray() and the ONE close choreography
every exit now calls, plus the handlers for the tray bridge's four signals
and the mini's close-app signal. The close predicate is both conditions,
trayLive && closeToTray: with the tray off a close QUITS, because gating on
the setting alone strands the process with no window on a desktop that has no
SNI host. close.accepted = false happens before the hide — hiding never arms
Qt's quit check, an accepted close does.

The miniplayer's enter and exit are rewired onto those two verbs, which is
what makes the tray toggle honest: the reference hides the main window behind
the tray's back and its second tray click brings the main window up beside a
live mini.

THE EXITS ARE FIVE, NOT FOUR. The contract enumerated four and gave the grep
that finds them; re-run today it returns a fifth, because the kiosk shell
grew its own drawn close button after the contract was written. It goes
through the same choreography. Four POC-NOTEs die here.

showFromTray also un-iconifies. Qt's hide() preserves windowStates where the
reference destroys the surface, so a window minimized before it was hidden
would otherwise come back minimized — present in the taskbar, invisible on
screen, which reads as the tray having done nothing.

qml/settings/AppearanceSettings.qml + src/settings_qt.rs — the three macOS
rows: the MENU BAR header swap, the macOS wording on Close-to-tray, and the
Hide-Dock row, all absent on Linux. minimize_to_tray gets storage and no row,
which is the owner's standing decision. And the tray-close-to-tray write arm
now pushes to the bridge beside its persist, so toggling it changes the very
next close with no restart.

src/mini_bridge.rs — close_app and the signal it routes through, so the
mini's red X will honour close-to-tray like every other exit. Its button is
B3's, so the chain has no caller yet.

Driven, not assumed: with the tray live and close-to-tray just pushed false,
the app-menu Close row QUIT the process — which can only happen if the push
reached the bridge, since the seed had set it true. And Shift+M then Escape
returns the main window pixel-identical, zero of 1.6 M pixels changed, with
the mini alone on a black framebuffer in between: the main window really is
hidden, and Qt's hide/show preserves its geometry with no restore call.

cc
2026-08-04 05:21:10 -06:00
vicrodh cd89b40945 feat(qt): the miniplayer window, shell and the two display surfaces
Block B2 of the miniplayer/tray contract, and the block that settles the
premise the whole port rests on: a second top-level Window in the same
QQmlApplicationEngine reads the SAME bridge singletons as the shell. Proven
by driving it — a probe window with one Text bound to QbzPlayer.npTitle
tracked the main window across a track change with no mirror code. Slint
needs ~155 lines of mirroring for this; Qt needs none.

qml/miniplayer/ (new, six files) — MiniWindow (frameless, transparent,
transientParent null, its own key host and its own onClosing), MiniShell (the
card, the artwork backdrop, the surface router), MiniCoverArt and
MiniExplicitBadge (the two components MiniBits.slint exports, which cannot
share one .qml file), and the compact and artwork surfaces.

The card is the window minus a 6 px gutter, its radius is 9 for the condensed
surfaces and 10 otherwise, and switching the surface RESIZES THE WINDOW —
540 to 178 and back, driven and measured, not inferred.

The backdrop lives inside a masked container. `clip: true` on a rounded
Rectangle does NOT round its children on this Qt build — theme/RoundedImage.qml
records that as measured fact — so without the mask the scrim and the
atmosphere's own opaque root would paint square corners over the card the
moment the backdrop is on. That is reachable before its toggle exists,
because the pref is co-owned with the shipping Slint app.

src/mini_bridge.rs — enter/exit, the mini's own key dispatcher, and toggle().
Re-entering an open mini re-raises it rather than doing nothing, which is
what the reference does and what present() will want.

src/hotkeys_qt.rs — action_for_mini_key, a SIBLING of action_for_key with the
context arm inverted. The main window still refuses Context::Mini and the
test that pins it is untouched; a new test pins the mirror.

src/hotkeys_bridge.rs — the ui.miniPlayer arm stops logging and toggles.

qml/shell/ViewModeMenu.qml — the Miniplayer row goes live with its own
dispatch arm, and it is hidden under the kiosk profile, 1:1 with
PlayerBarSmall.slint:819. The kiosk port landed first, so that gate was owed
here. Three comments claiming the row is inert are corrected.

Surfaces 0, 3 and 4 are clamped to the artwork surface until their content
exists (B3 and B4), because mini_surface and mini_default_view live in the
prefs file shared with the Slint app: a user whose Slint mini was last on
Queue would otherwise open onto a blank card with no visible way out. Each
block deletes its own entry from that clamp.

Enter does not hide the main window yet — window.hideToTray() is B6's, and
open-coding a bare hide() here is what the one-visibility-owner rule forbids.
So the main window stays visible behind the mini in this commit, by design.

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

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

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

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

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

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

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

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

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

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

cc
2026-08-04 02:47:56 -06:00
vicrodh 0d6e7d3498 feat(qt): QbzMini bridge, mini queue projection and mini prefs
Block B1 of the miniplayer/tray contract. No UI: this lands the state the
five surfaces will bind to, so B2 can mount a window against a bridge that
is already correct.

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

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

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

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

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

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

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

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

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

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

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

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

cc
2026-08-04 00:25:49 -06:00
vicrodh 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
2026-08-03 22:32:10 -06:00
vicrodh 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
2026-08-03 15:24:25 -06:00
vicrodh 5130e872e7 feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km
2026-08-03 13:32:35 -06:00
vicrodh 0cca9abc58 feat(qt): hotkeys core, QbzHotkeys bridge, shortcut i18n catalogs (B1)
km
2026-08-03 11:33:14 -06:00
vicrodh 0b6e8ed7c9 feat(qt): immersive split view, lyrics, track info, suggestions, queue tabs (B4)
km
2026-08-03 08:07:24 -06:00
vicrodh 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
2026-08-02 20:48:40 -06:00
vicrodh b5298b78bd feat(qt): immersive bridge, viz two-source enable, coverflow payload (B1)
km
2026-08-02 18:27:54 -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 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 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
2026-07-29 10:30:56 -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 f3a33245b2 feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
2026-07-28 18:12:31 -06:00
vicrodh ade156a72a refactor(qbz-qt): split QbzAlbum and QbzArtist out of the God-object
Two singletons rather than one merged object, per the owner: each maps to the
view it backs. Registering them and deleting the duplicates from bridge.rs is
one change, because wiring one without removing the other compiles fine and
leaves two independent states with the view frozen and nothing in the log.

view_param_id goes with them. It had no consumer in QML, verified, and none in
Rust once the two opens stopped writing it — so it is deleted rather than
carried into a new bridge for symmetry's sake.

bridge.rs is down to three domains: settings, search and playlist.

cc
2026-07-28 15:24:38 -06:00
vicrodh 5c631000b7 refactor(qbz-qt): split QbzLibrary out of the God-object
library_bridge.rs has sat in the tree unreferenced since the phase-23 work
stopped: no mod declaration, not in build.rs, zero call sites, and duplicating
members that were still live in bridge.rs. It could not have been wired as it
stood — its module was named qbz_library, which is ambiguous with the
qbz-library crate this binary depends on, so it never compiled. The module is
now qbz_library_bridge; the QML type name comes from the QObject, so QML is
unaffected.

Registering it and deleting the duplicates had to happen together. Wiring it
alone would have compiled cleanly and produced two independent Library states —
QML reading one object while Rust published to the other — with the view simply
frozen and nothing in the log.

Moved with it: four properties, five invokables, three signals, the seven
library-only ui() hops in main.rs, and thirty-four QML references. The
Connections blocks in LibraryView, Sidebar, QueuePanel, AlbumView and
PlaylistCollage retarget wholesale because every handler in them is a library
signal. ArtistView's does not: it mixes onLibraryArtworkReady with
onReleaseSectionReady, so it becomes two blocks — retargeting it whole would
have orphaned the releases pager, and QML resolves handlers lazily enough that
the discography would just have stopped loading with nothing in the log.

bridge.rs is down to 389 lines and five domains.

cc
2026-07-28 14:07:40 -06:00
vicrodh 0799d59e01 feat(qbz-qt): Local Library — folder tree, windowed album grid, detail
The sidebar has had a "Local Library" row since the shell was ported and
nothing behind it: the nav entry did not even navigate. This builds the view.

QbzLocal is its own domain bridge (the phase-23 pattern, one #[cxx_qt::bridge]
per domain) over local_library_qt, which consumes qbz-library for the scanning,
the folder-vs-tag album identity and the queries rather than reimplementing any
of it. The view carries the folder tree, the album grid, album detail, the
track tabs, sorting and search, plus the scanning and empty states.

Windowed from the start, not as a later pass: real local libraries reach 16k
tracks and that is exactly the surface where the Slint port freezes. Artwork
is id-keyed and evicted with the window.

Playback is source-aware — a local track plays from disk through the player's
play_data seam, because the Qobuz tier-walk needs a client and fails on every
local advance. TrackRow gains an opt-out for its drag source: a local row's id
is a local database row, and the sidebar's drop handler forwards whatever it
receives to `playlist add-tracks` as a Qobuz catalog id, so dragging one onto a
playlist would have silently added an unrelated track.

Not included, and tracked as follow-ups: Plex union and thumbnails, multi-select
and the bulk bars, ephemeral folders, the tag editor, A-Z jump strips, the
group-by dropdown, the tree's resize handle, per-tab scroll restore, and local
favourites on rows. Album detail renders inside the view rather than as its own
routed page.

cc
2026-07-28 13:49:15 -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
vicrodh 8c1ff34205 feat(qbz-qt): phase 17 — PlaylistView 1:1 + drag & drop as Slint
Full playlist surface: header with owner/foreign gating, play/shuffle,
favorite/follow optimistic, copy-to-library, pin, rename, delete,
per-row remove for owners, search filter, full sort set, custom-order
drag reorder persisted per user. Sidebar and every playlist card
navigate; card overlay/menu actions un-stubbed. Drag & drop per the
Slint gesture: row-body press-drag (>6px) from album/playlist/search/
library rows, window-top ghost pill, sidebar playlist rows drop-hot
highlight, drop -> add_tracks_to_playlist + reload reconcile; in-list
release reorders. Round-trip proven end-to-end on a throwaway playlist
(create -> drag -> drop -> refetch shows track -> delete).

km
2026-07-28 03:39:35 -06:00
vicrodh 22e65e939b feat(qbz-qt): phase 16 — card overlays everywhere + window-centered modals
Overlay opacity bindings referenced a nonexistent parent chain — all
card overlays evaluated invisible. Fixed per family: LibTrackCard
(scrim + fav/play/more row, full Slint menu), LibArtistCard rewrite
(circle-clipped scrim, play + more; play_artist with Popular-tracks
first and studio-discography fallback), LibPlaylistCard overlay fixed,
LibLabelCard verified overlay-less like the Slint. Modals: shared
AppShell-level layer (full-window scrim, window-centered panel)
replacing the view-local content-frame-centered ones.

km
2026-07-28 02:26:24 -06:00
vicrodh 9eb46d5e25 feat(qbz-qt): phase 15 — search 1:1 (cortinilla + full page with filters)
Intelligent search engine reused from qbz_app (SWR cache, interaction
recording, rank_within, top_for_query; app-menu kill switch on the
shared pref). Header field fully live: 220ms debounce, keyboard
semantics (arrows/Enter/Esc), clear, idle-close. Cortinilla as a plain
overlay (pointer stays on the field — Slint's exact reason): top
result, spec-ordered sections with View more, skeleton, row activation
dispatches (album/artist views, track play). Search page: 5 searchType
filter radios (Main Artist/Performer/Composer/Label/Release Name),
5 scope tabs, Most-popular hero, carousels, per-type grids/lists with
paged Load more. Perf: cortinilla 245ms cached / 1.1s cold. Fixed an
artwork attach bug the screenshots exposed (url/path slot mixup).
18/18 tests.

km
2026-07-28 00:49:53 -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 cc71337293 feat(qbz-qt): phase 13 — correct card families in Library + Discover tabs
Library audit: the phase-7 conversion never actually mounted the shared
AlbumCard — ALL kinds used the invented feed card. Now per-kind 1:1:
LibTrackCard (discover/TrackCard replica with wired next/later/queue
enqueue), shared AlbumCard for albums, LibArtistCard (ArtistGridCard
circle), LibPlaylistCard (collage, follow-aware), LibLabelCard
(gradient + contain-fit logo), list rows rewritten to the real
FavoritesView inline row, Tracks tab on a TrackRow replica. Discover:
Editor's Picks and For You tabs rendered from the same discover-index
fetch, prefs-ordered per DiscoverPrefsStore (incl. mostStreamed#album
variant, Qobuz Mixes gradient tiles, Pinned in the user's own position);
Recommendations tab gated on the user's pref with an explicit engine
placeholder. No refetch on tab switches.

km
2026-07-27 22:35:21 -06:00