Commit Graph
234 Commits
Author SHA1 Message Date
vicrodh 0e5f160a7c fix(myqbz): stamp the cover when an item is added to a collection
`mixtape_collection_items.artwork_url` is a SNAPSHOT written once at add
time. The grid tile and the hero mosaic read that column and stop — only
the detail row has a backfill — so an item stored without a cover is
blank on those surfaces for the life of the row.

WHAT THE DATA SAID, against two hypotheses including mine. The owner
reported albums added "from Qobuz" arriving coverless, and the obvious
suspects were a narrow ImageSet read and the disco builder. Querying the
live library.db settled it: 8 of 35 rows have an empty artwork_url and
every one of them is `item_type='album' source='local'`, while every
Qobuz album row carries a url. So the confirmed gap is the LOCAL album
page's cassette button, which passed `artwork_url: None` outright — the
only call site in the app that could produce those rows. It now stamps
the album's own cover path, which is correct to store here: the mosaic
passes local paths through unrewritten, and that is what lets a
collection of local albums render with no network at all.

Also stamped, where the remote url was already in hand and only an empty
string was being sent: Search, Playlist, Library, Artist and Label track
rows. AlbumView takes the HEADER's url rather than the row's — album-view
track rows are published with an empty artUrl by construction
(album_qt.rs says so in the field's own doc), so the obvious edit there
would have been a no-op, and every track of an album shares its cover.

Four sites still send nothing and now SAY why in place: the three
now-playing bars and MixView have only a file:// cache path or no url at
all, and a cache path must never be stored — other machines read this
column. Those need a remote-url field on their documents first.

The `image.best()` widening in album_qt/bulk_tracks_qt rides along as
hygiene, not as the fix: `thumbnail.or(small)` was the outlier in a file
that reads every other cover through `best()`. An adversarial pass over
494 real ImageSets found ZERO albums lacking both fields, so it was not
what the owner hit — it is simply the read the rest of the file already
agreed on.

STILL OPEN: if the owner's two albums were Qobuz after all, their urls
ARE stored, and the symptom is on the render side — the mosaics have no
fallback where the detail row does.

cc
2026-08-22 05:10:17 -06:00
vicrodh ef333cd7ec fix(login): point a never-had-an-account user at the door that is for them
Local-only mode already WORKED — verified by driving a genuinely fresh
profile (no credentials, keyring unreachable): "Start offline" enters the
guest profile, Local Library opens with its proper empty state, Settings
renders in full, zero QML complaints. The capability was never the
problem. Two pieces of the screen were.

1. THE SIGNPOST WAS OFF FOR EXACTLY THE RIGHT PERSON. The callout with
   the caret pointing at the Start-offline link was gated on
   `hasPreviousSession && connectivity === 2`. `hasPreviousSession` is
   seeded from `load_last_user_id()` — None on a fresh profile — and only
   ever set true by a successful Qobuz login. So the one piece of UI that
   says "you can use this without an account" could not be seen by
   anybody who had never signed in. The gate was written for the
   returning-user-with-no-wifi case and the guest case was bolted onto
   the link alone.

2. THE COPY WAS FALSE. Once that box is visible to a first-run user with
   working internet, "No internet connection" is simply untrue, and the
   offline placeholder said the same thing after entering: measured on a
   fresh profile, the log read `connectivity Up, induced false,
   offline_session true` while the screen claimed the network was down.
   Both now branch on which of the THREE states this is — induced
   offline, no connectivity, or no account — and the captive-portal line
   is gated on connectivity too, so it cannot appear under a "No Qobuz
   account?" heading.

Two new msgids in all seven non-English catalogues.

Verified after the change on a fresh profile: the callout appears, the
link is reachable, and the placeholder reads "You're using QBZ without a
Qobuz account."

HARNESS NOTE for whoever tests this next: XDG_CONFIG_HOME sandboxes the
token FILE but NOT the system keyring, and a rejected token there calls
`clear_oauth_token`, which deletes the keyring entry for the real user.
Point DBUS_SESSION_BUS_ADDRESS at nothing so the Secret Service is
unreachable. (The file stays authoritative, so nothing is lost — but the
run is not clean either.)

cc
2026-08-22 05:09:56 -06:00
vicrodh 750c59034b feat(qt): Excel-style multi-select — Shift ranges across every surface
Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.

NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.

The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.

Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.

Three judgement calls, stated because they are not forced:

  - Library gets TWO models, one per kind. Its rows are mixed, and one
    anchor would let a Shift-click on a track sweep up the albums drawn
    between it and the anchor.
  - Ranges run over the VISIBLE rows. Local Library's search box is a
    view filter, and a range over rows the user cannot see is not the
    range they asked for — the same call `select-all` already made.
  - The Local Library tree rail keeps plain toggles. Its selection lives
    in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
    an anchor off; teaching the Rust side about an anchor is its own
    change, and the file says so where a reader would expect otherwise.

The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.

Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.

cc
2026-08-22 02:03:10 -06:00
vicrodh 7fc7605887 feat(search): the Tracks results use the app's full track row
Search already mounted the shared TrackRow, but had never switched on
any of its affordances: no artwork, no artist link, no album column. So
the one surface where people are most often hunting for an album gave
them no way to reach it except the context menu's "Go to", three clicks
deep, while every other track list in the app links straight through.

Turning the arms on is most of it. The missing half was in Rust:
`search_qt::TrackRow` carried `albumId` but not the album TITLE, and the
row draws `item.album` as the column's label and uses `item.albumId` for
the click — so without the title the column would render empty and the
link would be invisible. `track.album` was already being read one line
above for the id.

No layout work: TrackCols already reserves the album cell and its gap
behind the same `showAlbum` flag, so the widths redistribute themselves.

Verified by driving the app: a search for "beatles", the Tracks tab, and
a click on "Abbey Road" in a row opens Abbey Road (Super Deluxe Edition).

cc
2026-08-22 01:43:50 -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 bbe9f25689 refactor(settings): Plex gets the same header as Jellyfin and Subsonic
The three media servers sit one under the other and Plex was the odd one
out: a small-caps "PLEX" eyebrow with no brand mark in a 64px band,
against the 44px SourceIcon + 14px DemiBold title the other two use.
SourceIcon already draws the full-colour Plex mark untinted, so this is
the same header, the same metrics and the same glyph pipeline.

NOT folded into MediaServerSettings.qml: that component is one form
twice — address, account, password, connect, sweep — and Plex's body is
a different shape, a PIN pairing flow with a server picker and a
per-library table. Only the header is the same.

One affordance the other two have is deliberately not copied: the
"· <server name>" suffix. PlexFields carries no name — pairing stores a
url and a token — so the row would be permanently invisible. An empty
binding is not parity, it is a dead branch.

cc
2026-08-22 00:01:31 -06:00
vicrodh 002231b46b fix(settings): the Library > All select is the same size as every other
`sm: true` made this the ONE select in Settings drawn at the small size,
which reads as a rendering bug sitting directly under the full-size
Album grouping select. The control-alignment standard has one size for a
settings row.

cc
2026-08-22 00:01:31 -06:00
vicrodh f50c1b5188 feat(settings): the library folder table becomes its own page
The folder block — toolbar, add-path row, filter, the row-per-folder
table and the scan progress — is the tallest thing in Settings and it
sat at the TOP of Local Library, pushing Albums view, Library > All,
Maintenance, Danger zone and the three media servers off the first
screen. Behind a Manage row, the whole panel fits one screen again.

The shape is the one Blacklist already uses for its manager, and this
view is deliberately its twin: same 64px summary row, same metrics, same
route-recorded-by-the-caller contract.

Nothing about the table changed. LibraryFolderTable.qml is mounted
verbatim, still driven by the settings document and still acting through
the settingsString action keys.

Two things had to travel with it, because SettingsView used to supply
them and a routed page has no access to that:

  - LibFolderEditModal, or the per-folder pencil would open nothing,
    silently.
  - the refresh on entry. A view reached by Back runs no per-view load,
    so it asks for its own, where BlacklistManagerView puts its reload.

Verified in a running window: the route mounts, the seven folders render
with their per-row actions, and Back returns to Local Library with the
section intact.

cc
2026-08-22 00:01:13 -06:00
vicrodh e90369d931 refactor(settings): regroup Appearance around what each row answers
Row wiring is untouched — every block moved verbatim and nothing here
changes what a control does. What changed is the grouping, so the group
order can no longer be read out of AppearanceSettings.slint:

  THEME                  the picker and the rows that exist only for it
  TYPOGRAPHY & LANGUAGE  Language, Interface size, Font
  NAVIGATION             was LIBRARY & VISUALS minus the visual rows,
                         plus the three Purchases/menu rows
  SEARCH                 new: Intelligent Search came out of THEME,
                         Immersive search out of TYPOGRAPHY & LANGUAGE
  PLAYER & VISUALS       was PLAYER VIEWS, plus the visual rows
  WINDOW                 WINDOW TITLE + TITLE BAR + WINDOW CONTROLS
  SYSTEM INTEGRATION     NOTIFICATIONS + SYSTEM TRAY
  RENDERER               unchanged

Four rows are now ABSENT rather than rendered-and-disabled, because a
control that cannot do anything should not be on screen: Purchases in
title bar (Show Purchases off), Window controls position (system title
bar on), Close to tray and Tray icon variant (tray icon off). Each keeps
its stored value, so turning the parent back on restores the answer
already given.

The My QBZ row loses its fixed logo preview: it renames the hub and
never picked an icon, so the image was decoration in a control column.

Verified in a running window, English and Spanish.

cc
2026-08-22 00:00:25 -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 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 b970e8f853 perf(qt): mount the settings panel in view, not all nine
The nine panels were siblings gated only by `visible:`, so every mount
of Settings built the 30 KB Appearance panel, the 19 KB Plex panel and
the rest in order to show one of them. Measured before the change,
Settings was the slowest route in the app.

A Loader with `active:` is the fix that shipped for the Discover tabs
and it is the fix here. Measured after: built 326 -> 167 ms, to-idle
392 -> 224 ms.

The two overlay modals (LibFolderEditModal, DacWizardModal) are still
built unconditionally and are the next item of the same kind; they own
their own open state, so gating them is a separate change.

cc
2026-08-21 23:59:29 -06:00
vicrodh dc8cb1fa32 perf(qt): build only the discover rails that fit the viewport
2026-08-17 fixed the four Discover tabs, which were all instantiated
because `visible: false` hides an item without stopping QML from
building it. This is the same disease one level down and it is what was
left: a Repeater builds EVERY delegate in its model, so a ten-section
tab built ten rails, each rail's horizontal ListView then created its
own delegates during the first layout pass, and the viewport has room
for about two and a half.

That work is what `to-idle` was made of — the rails nobody could see
paying for their cards before the GUI thread could reach the event loop
and let a frame out.

The seed is one viewport and the rest arrive after the first frame. A
rail is ~328px with the Column spacing against a ~740px pane, so three
covers the fold; +2 per tick reaches even the twelve-section For You tab
inside ~80 ms. The `active &&` on the visibility gate is load-bearing:
a Column skips invisible children entirely, but an active:false Loader
is visible and 0px tall, which would reserve a spacing slot per pending
rail and collapse it as the rail lands.

Honest limit: this did NOT turn out to be what fills the blank stretch
between the acknowledgement frame and the content. Library has no such
ramp and shows the same shape, so that hypothesis is refuted and the
remainder is unattributed.

cc
2026-08-21 23:59:29 -06:00
vicrodh 5b274cab57 perf(qt): acknowledge a route change on the next presented frame
Measured by driving the real window and diffing the captured frames: a
click on Discover > Home left the window with ZERO pixels changing for
590 ms — the flyout did not even close. The click handler, the flyout
close, the sidebar highlight, the page blank and the whole mount all ran
inside ONE turn of the event loop, so the first frame that could be
presented was the one that already had the finished page in it.

The breakdown matters more than the total, because only one part of it
is what it looks like:

    navigate_to (Rust, GUI thread)      7.6 ms
    bridge queue hop                    1   ms
    the QML mount (`built`)           263   ms
    post-mount work before a frame   ~290   ms   <-- `to-idle`

`built` is under half of it. `to-idle` is the number that matches the
freeze, and any future work here should be judged on that one.

So the route is now committed in two phases: arm on the change, build on
the next PRESENTED frame (frameSwapped). Frame N carries the cheap half
— flyout gone, highlight moved, page blanked — and frame N+1 pays for
the mount. Acknowledgement measured at 73-90 ms across five routes and
repeat runs, against 590 ms before.

Two things this had to get right, both caught by capturing frames:

  - The backstop timer was a RACE at 32 ms. Render-at-next-vsync
    (16.67 ms here, per QSG_INFO=1) plus a QUEUED frameSwapped delivery
    lands in ~16-33 ms, so Home acknowledged at +90 ms while Library got
    nothing at all on the same build. 250 ms puts the guard outside that
    range; it now only covers the hidden-window case it was written for.
  - During the armed frame `viewLoader.item` is still the OUTGOING view,
    so the incoming route's discriminators were applied to it. The
    activeTab Binding wrote "home" onto LibraryView — which has an
    activeTab, so the typeof guard passed — emptying its body and
    restarting the page fade over the blank. Both Bindings now require
    the commit to have happened.

Not asynchronous: incubation is time-sliced and was reverted the day it
was tried; every view is still built synchronously.

cc
2026-08-21 23:59:10 -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 a922bdd316 feat(qt): disc covers, a corner close, and an icon for Rip
Three things the owner asked for after driving the first build.

COVER ART. A SACD image carries none, but the file it was downloaded as
almost always sits beside one — `cover.jpg` next to the `.iso`, or one level
up when the rip is split into "Disc 1"/"Disc 2" folders, which is the shape of
the owner's Rheingold. `find_folder_artwork` already knows that walk, so this
reuses it rather than growing a second rule about where covers live.

CLOSE moves to the pane's top-right corner, for all three media. It used to
sit in the action row beside Shuffle — a destructive action one pixel from
"play", which is where a hand goes without looking. It is also OUTSIDE the
Flickable now: scrolling a 24-track disc must not carry the only way to close
the session off the top of the screen.

THE RIP ICON is a disc with a folder, drawn as a Lucide-style outline rather
than traced from the owner's PNG. The argument is the size it renders at: at
15px a solid, detailed glyph turns to mush while an outline still reads, and
the comparison was rendered at both sizes before choosing.

Adding it also found a gap in my own tooling. `icon_tint_qt.rs` re-tints from
MASTERS at theme-change time, and `masters_cover_every_shipped_glyph` failed
the build for a glyph that was baked but had no master — six files written,
one line forgotten. `bake_icon.py` now registers the master itself, because a
step that has to be remembered is a step that eventually is not.

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

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

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

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

Four new strings in all eight locales.

cc
2026-08-21 02:27:40 -06:00
vicrodh 452589ddaa feat(qt): open a SACD image and play it — the disc names itself
`Open > Open SACD image…` picks a .iso, reads its stereo area and publishes it
as the ephemeral session. Nothing lands in library.db.

Unlike a CD, a SACD needs no network to be named: the Master TOC carries the
album title and artist, and the area TOC a title per track. The owner's disc
opens as "Wagner: Das Rheingold", 24 tracks, every one with its German title
and "Berliner Philharmoniker, Herbert von Karajan, etc." — all of it read off
the image.

Routing is one line in `LocalSource::playback` and none in the player: a
`sacd:` reference rides the existing DsdFile ticket, because `open_dsd` is the
funnel all six player-side DSD constructions already go through. Teaching it
there is what makes the PCM, DoP, native and gapless paths inherit disc images
for free.

Driven end to end on the owner's Rheingold: the tab reads "Wagner: Das
Rheingold 24", the badges read HI-RES / DSD64, and pressing play took the DoP
route —

  Player: DSD track — DSD64 via DoP (176400 Hz carrier)
  [ALSA Direct] DoP hardware configured: 176400Hz, 2ch, S32_LE
  Audio thread: DoP playback STARTED

which is the outcome that matters: the disc's DSD bits reach the DAC without
being converted to PCM at all.

Six new strings in all eight locales. Unsupported cases are named rather than
approximated — a multichannel-only area, a DST-compressed one and a file that
is not an image each get their own message.

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

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

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

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

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

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

Seven new strings in all eight locales.

cc
2026-08-20 23:29:21 -06:00
vicrodh 0e9721d24b refactor(qt): reorder the Local Library header and label the Open menu
Owner's layout: the two maintenance glyphs sit together and the action that
produces new content reads last —

    Local Library  (settings) (resync)  [folder] Open v

Open also stops being a bare glyph. A folder icon on its own reads as "browse
folders" when there is a tab literally called Folders two rows below, and this
control is the one door for every medium the pane can hold: a folder now, a CD
and a SACD image next. It is a 30px / radius-6 chip with icon, label and
chevron — the same small toolbar-control contract this header's selects use.

cc
2026-08-20 22:24:11 -06:00
vicrodh 9666ef129e feat(qt): give the ephemeral pane an album header, a shuffle and zebra rows
The pane opened on a bare title, a track count and the folder PATH — a
filesystem readout for something the user is about to listen to. For the
common case this pane IS an album page: one folder, one album, one cover.

It now reads like one. The cover is 224px, the size LocalAlbumHeader uses, so
an opened folder and an indexed album never present the same record at two
different scales; the cell collapses entirely when the session has no art
rather than reserving an empty square. The second line trades the path for the
total playing time, formatted by the same `total_duration` helper every other
Local Library surface uses. Play gains a Shuffle beside it, with the same
`(shuffle)` shape `play_album` has, and the Clear stays — it is the only way
to close a session, and with it the only way to make the tab go away.

The per-block header now draws only for MULTI-album sessions. Adding the big
header made it a stutter for single-album ones: the same cover, the same
title and the same count, twice on one screen.

Track rows get `zebra: true`. `TrackRow` already had the property; this pane
was simply the surface that never set it.

The shuffle's generator was replaced before it shipped. The first version was
a bare xorshift with a comment claiming a folder shuffle "is not a place that
needs a good generator" — measured over eight consecutive shuffles of a
ten-track album, one track opened FOUR of them. It is splitmix64 now, seeded
with a counter as well as the clock so two shuffles inside one clock tick
cannot repeat, and there is a test asserting no position is pinned over 2000
runs. Shuffle quality is something the listener sees directly.

cc
2026-08-20 22:23:56 -06:00
vicrodh 45c686fab0 fix(qt): take the view to the session you just opened, every time
Reported: opening a folder left the view on whatever tab was already showing.

The handler watched `ephemeralActive`, and that flag is ALREADY true when you
open a second folder over a first — or when the boot restore opened one before
you ever touched the view. So it fired exactly once in the life of the app and
then never again, which is precisely the symptom.

It now watches an open SEQUENCE that changes on every open. The discriminator
was already in the code and did not need inventing: `open()` and `open_path()`
pass a runtime to `scan`, `rehydrate()` passes None. Bumping only for the
former is what keeps a remembered folder from stealing the tab you actually
navigated to at startup.

Also restores `onEphemeralActiveChanged`, which I deleted by accident in the
label commit: a range replacement swallowed the handler sitting between the
two edits. It is the fallback that sends you to Albums when the session closes
under you, and without it closing a session left `activeTab` pointing at a tab
that no longer exists — a blank content area. It had been smoke-tested BEFORE
that edit, so the test was valid when it ran and stale by the time it landed.

Verified in the reported scenario: standing on Albums with one session open,
opening a different folder switches to the tab, the label changes and the
count follows.

cc
2026-08-20 22:23:34 -06:00
vicrodh cb4c03ca46 feat(qt): one Open menu and one Refresh menu in the Local Library header
The ephemeral pane used to be a third arm of the Folders tab, reachable only
through a folder-open icon buried in the tree rail — one arm of one mode of
one tab. It also HID the folder browser while it was open. Neither survives
the media this pane is about to hold (a CD, a SACD image).

Row 1 of the header now carries two menus, both `CardMenu` (the shared menu
surface, no new component):

  - `Open`, always present, is the one door for every medium. Only "Open
    folder…" is wired here; the disc entries land with their readers.
  - `Refresh` replaces the Plex-only re-sync icon (#573). Rows are APPENDED
    per configured source rather than rendered disabled, so a user with no
    media servers sees local folders and nothing else. It needed no new Rust:
    `plexAvailable`, `mediaHasJellyfin`, `mediaHasSubsonic`, `syncPlex()`,
    `mediaSync()` and the settings' own "library-scan" all already existed.

The ephemeral session becomes a tab of its own, present only while a session
is. Two failure modes are handled because the tab can VANISH under the user:
closing the session falls back to Albums instead of leaving `activeTab` on a
tab with no body, and opening one navigates to it — while the pane was an arm
of Folders that was implicit, and with a tab of its own, opening a folder from
Albums would otherwise do nothing visible.

`load_tab_impl` gains an "ephemeral" arm: nothing to load (the session is in
memory), but without the arm every switch to the tab logged "unknown tab",
which is what a real routing bug looks like.

Driven under Xvfb, not inferred: both menus open, the gated rows match the
configured servers, opening lands on the tab, the badges read DSD64, closing
removes the tab and falls back to Albums, and the folder tree is now visible
WITH a session open.

cc
2026-08-20 21:14:36 -06:00
vicrodh a7b7c1b236 fix(qt): light the playing row for a downloaded track, in every track list
The now-playing affordance — the accent pill on the row's left edge and the
eq bars in the number cell — never appeared for an offline row on the very
view it was played from. `TrackRow` asked one question,

  QbzPlayer.npTrackId === item.id

and for a `qobuz_download` row the two sides come from different id spaces:
the queue id is the QOBUZ CATALOG id (the offline tier is keyed on it) while
every Local Library list draws the row with its `library.db` id. They never
matched, so the row sat dark while its own audio played.

The reference has a second published id for exactly this and the port never
carried it over: `NowPlayingState.local-track-id` (playback.rs:1978-1986).
This adds its twin, `QbzPlayer.npLocalTrackId`, and makes the row match
either.

Published for Offline rows ONLY, and that restriction is the safety of it:
`source_item_id_hint` is a shared slot carrying a Plex ALBUM key and a media
server's own item id for those sources, so publishing it unconditionally
would hand a list a number from another id space and light the wrong row.
It stays empty elsewhere, which makes the second test inert there.

Scope checked by driving the window rather than reasoned about: the pill and
the play glyph land on the right row for local user files, Plex, Jellyfin and
Subsonic both before and after this change — offline was the only source
whose id diverged. The other three surfaces that ask the same question need
no alias: the queue panel and the kiosk album carry queue ids, and the
purchases list carries catalog ids.

qbz-qt 431/431, 4 QML audits green.

cc
2026-08-20 18:16:45 -06:00
vicrodh 900517db57 fix(qt): size the filter flyout to its content, not to 372px
The Slint original sized a fixed card around THREE source chips. This
port now has five — Plex plus the two media servers — and the row ran
to the card's edge, with the last chip all but touching the border.

The 372px becomes a floor and the width is derived from the widest chip
row plus the 16px padding on each side, capped so the card can never run
past the left edge. That also survives a UI-scale bump or a language
whose labels are longer, neither of which a fixed number can.

`implicitWidth` is safe to measure here: a Row sums its children and a
non-wrapping Text reports its natural width, so nothing in the chain
reads the card width back and there is no binding loop. An invisible
chip contributes nothing, so the card is narrower when a server is not
configured.

cc
2026-08-20 16:38:28 -06:00
vicrodh 021b5609e1 feat(qt): the Albums funnel survives navigation and restarts
The filter worked but forgot itself twice over: leaving Local Library
reset it, and so did closing the app. The first one is not a bug in the
view, it is what this port does — navigating away DESTROYS the view
(project_qt_nav_eager_tabs), so a `property var filter: ({})` cannot
outlive a trip to Discover.

Both follow from one move: the funnel lives on the BRIDGE, mirrored into
the shared ui_prefs.json. The bridge outlives the view, and the file
outlives the process, so one property buys both kinds of persistence.

`setFilter()` is the only writer, so a mutator cannot change the funnel
and forget to persist it, and the view re-seeds from a Connections on
the qproperty rather than only at construction — a republish has to
reach a view that is already open.

The saved funnel is pruned where the source GATES are published, not
somewhere else, so the two can never disagree: a restored `jellyfin`
tick with no Jellyfin configured would match zero rows while the chip
that clears it is hidden by the same gate — an empty grid with a badge
counting a filter the user cannot see. Only the media-server keys are
pruned; local/offline/plex are always meaningful words.

Verified: ticked Jellyfin, went to Discover and back (badge and grid
held), restarted the process (held), pressed Clear (ui_prefs went to
{}). The prune is reasoned, not exercised — both servers are configured
on this box, so no gate is off to test it against.

cc
2026-08-20 16:38:28 -06:00
vicrodh 1a2ebcf146 fix(qt): re-seed a line edit on the CHANGE, never on the focus loss
`QbzLineEdit` restored the owner's value with

    Binding { target: input; property: "text"; value: root.text
              when: !input.activeFocus }

and a Binding re-asserts whenever its condition flips, so BLURRING the
field wrote `root.text` back over whatever had been typed.

A SEARCH field never noticed: it fires `edited()` per keystroke, the
owner republishes, and `root.text` is already what the user typed. A
FORM field is the opposite — it reports on `committed()` and the owner
holds the value until Save — so `root.text` is still the stored value,
which for a password is the literal `""` that is deliberately never
prefilled. Every blur erased the field.

Measured in the media-server panel: username and password wiped each
other, so a Subsonic server could not be signed into by hand at all.
The stored state was in fact correct the whole time — `onCommitted`
fires on focus loss and captures the value before the Binding clears
the view — which is why this survived the Jellyfin round: the form
worked, it just did not look like it.

A republish IS a change to `root.text`; a focus loss is not. Reacting
to the change alone keeps the documented case (an external Reset
re-seeds an unfocused field) and drops the destructive one.

Verified in the window: password survives blurring into the username
box; the Tracks search keeps its query after a blur; the × still
collapses the expandable search with no stale query snapping back.

cc
2026-08-20 15:27:22 -06:00
vicrodh 8b4e0e92b8 fix(qt): treat every server source as non-catalog, not just Plex
Three places still spelled "not a Qobuz row" as "local or plex", which
made a Jellyfin or Subsonic row fall into the catalog class:

- Library ALL feed stamped the album source from the ID PREFIX
  (`plex:`), so every media-server album came out as "local" and drew
  the local hard-drive badge. Reads the row's own `source` word now —
  byte-identical for local and Plex, correct for the rest. The same
  failure `local_rows::badge_source` had.
- TrackRow's `localSourceRow` gated "Add to playlist" and the offline
  download entry. A Jellyfin row counted as a catalog row, so the menu
  offered to send a namespaced synthetic id to the Qobuz playlist API,
  which adds an UNRELATED track. An allowlist, not `!== "qobuz"`:
  "offline" is an offline-CACHED Qobuz row whose id IS a catalog id.
- AlbumCard hid "Block this album" on local/plex only, so a server album
  offered to blacklist an id the Qobuz catalog never had.

`feed_track_to_queue` is taught the words too. Media-server TRACKS do
not ride that feed today (only their albums do), so that one is the
guard learning the vocabulary before it is needed rather than a live
fix — its existing test, which pins that a local id must never resolve
as the Qobuz track carrying the same number, now covers all of them.

cc
2026-08-20 14:12:53 -06:00
vicrodh 322b9374fb feat(qt): Jellyfin and Subsonic filter chips in Local Library
The Source row of the filter popup offered Local / Offline cache / Plex,
so a media-server library could be browsed but not narrowed — no Plex
equivalent existed for it.

Adds a chip per media server, each gated on a new bridge property so an
unconfigured server never shows a chip that can only ever return zero
rows (Subsonic stays hidden here, Jellyfin appears).

The fold in applyFilter is the part that is easy to get wrong: rows are
stamped with the BRAND the server reports — navidrome, gonic, airsonic,
astiga — while the chip is the PROTOCOL. Without folding those words to
"subsonic", ticking any source filter would silently drop every row of a
Subsonic library instead of filtering it.

Verified against the live Jellyfin bench: with the chip on, the grid
holds only Jellyfin-badged albums.

cc
2026-08-20 13:49:37 -06:00
vicrodh d5ce8bcaeb fix(qt): offline-cache rows, Jellyfin playback, and media art on the bar
Three defects the owner found by using it, all mine, all from the same
root: a source word that stops at Plex.

1. AN OFFLINE-CACHE ROW PLAYED NOTHING.

     audible: file not available at …/audio/tracks-cmaf/426056576

   `SourceId::from_word` folds `qobuz_download` into LOCAL because the row
   IS a file — but for a CMAF-cached download that "file" is the offline
   cache's own container and only `qbz-core`'s offline tier can read it.
   IC-12's first pass declared those rows ours and handed them to the
   local audible step. They now stay on the Qobuz funnel, which is where
   they were before stage 3 and where their `QueueTrack.id` — the real
   catalog id — is meant to be resolved. The symptom was the bar, the
   queue and MPRIS all changing while the previous track kept playing.

2. A JELLYFIN ROW CHANGED THE BAR AND PLAYED NOTHING.

   `local_queue_track` folded everything that was not Plex or a download
   into "local", so the row entered the queue wearing a source word that
   disagreed with its namespaced id and carrying no server id at all.
   Both media words are now arms of their own, and the hint carries the
   SERVER's item id — which `claim` prefers, because it survives a cache
   rebuild that renumbers rowids.

3. MEDIA ART NEVER REACHED THE BAR, THE QUEUE OR MPRIS.

   Those surfaces resolve an `artwork_url` string with no row in hand, so
   `classify` has only the SHAPE to go on — and a Jellyfin image tag or a
   Subsonic coverArt id has no shape worth reading. `local_queue_track`
   now stamps the token SOURCE-TAGGED (`jellyfin:<albumId>/<tag>`) and
   `classify` grows one arm that DELEGATES it to
   `registry().artwork_token` instead of guessing. One arm, not 111 call
   sites, and it shares its cache entry with the grid because it is the
   same call.

Verified in the running window: a Jellyfin album streams and plays —
`[JELLYFIN/STREAMING] 331 MB @ 12 MB/s`, decoder up, 44.1 kHz to the DAC.

429/429.

cc
2026-08-20 12:57:36 -06:00
vicrodh 3cfce8a769 feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.

Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.

CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.

THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:

1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
   not in the QML module. It resolves fine on disk, compiles nothing,
   and blanks the WHOLE settings page at runtime. All three existing
   audits passed. `qml_module_registration_audit.py` is the missing
   check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
   settings DOCUMENT, unlike Plex's toggle which reads a bridge
   property, so every mutation has to republish it — connect, enable,
   disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
   `local_rows::badge_source` had never been taught the two words and
   folded them into `_ => local`. Not cosmetic — that string is what the
   source chips filter on.

`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.

END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.

428/428.

cc
2026-08-20 10:57:28 -06:00
vicrodh 321d8fe753 fix(qt): align the playlist manager's card grid with its own headings
The grid delegate carried x: 32 on the delegate ROOT. A ListView owns its
delegates' position, so an x set there is not reliably the one that survives
layout, and it did not: the cards sat about 24 px left of "Folders (12)" and
"Playlists (9)", which PmPageHead draws at 32 through its own Column. Headings
at one inset and cards at another, on the same page.

The list and tree delegates right below it already use the shape that works —
a full-width delegate with the inset on the child — so the grid now does too.
The inset lives where the view has no opinion about it.

cc
2026-08-19 23:56:08 -06:00
vicrodh 7e233104f5 feat(import): playlist files, JSON, ListenBrainz and Last.fm as import sources
Implements qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md,
whose §11 the owner ratified 2026-07-31. PlaylistSource is an enum, not a trait
(ADR-007): the set is closed, there is no plugin story, and every arm resolves
to the same ImportPlaylist the URL scrapers already produce — so the matcher,
the 2000-track split, the sink and the summary never learn a second kind of
source exists.

import_prepared_playlist takes the playlist BY VALUE and
import_public_playlist becomes a shim. That kills the double scrape the Tauri
re-fetch-on-execute left behind, and it is not optional for the new sources:
the rfd bytes of a picked file are gone by execute time and the dialog cannot
silently reopen. It also closes a real race — open() resets the session, so a
modal reopened between the Import click and the task's read handed a detached
task None.

FILES read the track list ONLY. The paths inside are never opened, copied or
added to the Local Library, and the modal says so in a persistent row rather
than a tooltip. Content sniffing beats the extension, with an HLS guard because
.m3u8 is also the stream-manifest extension, and a utf8-then-Windows-1252
fallback because .m3u and .pls predate UTF-8 and "Björk" has to stay matchable.

JSON locates the track list BY SHAPE. Depth is a DoS bound, not a locator: the
array sits at 0, 1, 2 or 3 levels depending on the export, and a hard two-level
rule drops an ordinary GraphQL envelope. Container demotion keeps a discography
from importing album names as track titles. Walls at 16 MiB, depth 8, 2M nodes
and 10k tracks.

SERVICES are public reads with no authentication; a connected account only
prefills the field. No qbz-integrations edge — its ListenBrainz client returns
tracks with no duration and drops the playlist title, both verified in its
source, and a direct GET /1/playlist/<mbid> reads all three in one request.

A Last.fm STATION IS A RADIO, and ?page=N is not pagination. Measured against a
live profile: pages 1 and 2 overlapped and batch sizes varied (20, 20, 36). The
first cut fetched 20 pages, never hit its empty-page exit, and returned 469 rows
holding 198 distinct tracks. It now dedupes as it draws, stops after two dry
draws, and stops at MAX_STATION_TRACKS = 200 — the number shown in "Found N
tracks" has to be one we can deliver, and a station is dynamic enough that a
bigger single import buys little (owner ruling: no false expectations of
"traeré casi 500" when we bring half). A failed draw no longer fails an import
that already has rows; Last.fm rate-limits a burst of these.

The summary reports THREE DISJOINT NUMBERS that sum to the total. skipped is
now counted from the matches that found nothing, not inferred as
total - matched, which folded duplicates into failures: that same import told
the user 271 tracks were skipped when 255 of them had matched perfectly well
and been deduplicated before the add.

23 new strings across the seven translated locales; en is the sparse fallback
catalogue and needs none.

cc
2026-08-19 23:55:56 -06:00
vicrodh c11bdf0654 feat(qt): bring back the New playlist modal, and delete the shortcut
The sidebar "+" called create_playlist(), which made a playlist named literally
"New Playlist" and navigated to it. Four of the modal's five fields had no other
door, and one had NO door at all:

  name          hardcoded          reachable in Edit playlist
  description   always empty       reachable in Edit playlist
  folder        always root        reachable in the sidebar row menu
  public        always false       NOWHERE
  offline-only  from connectivity  NOWHERE for an online user

That last row is why this counts as a regression rather than a rough edge.
offline_only decides whether the playlist is a local_playlists row
(local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity,
and the shortcut derived it from connectivity alone — so someone with a Qobuz
session could not create a local playlist without pulling the network cable.

Ported against CreatePlaylistModal.slint plus on_create_playlist
(main.rs:21107) and on_submit (:21496). The submit branches where the reference
branches, and a creation forced local by being offline KEEPS the flag, as the
reference's own note says, so Edit can unmark it later to enable "Upload to
Qobuz".

Two calls made here:

- Create rides the QbzPlaylistEdit singleton rather than getting its own, where
  the reference splits them. Same domain — a playlist's own metadata — with two
  documents that never interact, and a second QObject is a second boot() to
  forget; that bridge's header already documents a missing boot() swallowing
  publishes silently and forever.
- The folder picker hides on the offline-only arm. folders_qt::move_playlist
  takes a u64 and a local:<uuid> has none, so the reference shows the control
  and discards the value.

The shortcut is deleted rather than left beside the modal: a second creation
path that skips it is the fork the port rule forbids. All thirteen strings were
already in the catalogues.

cc
2026-08-19 23:55:25 -06:00
vicrodh e1876c15f0 feat(qt): the artist page's radio disc goes live, with a start-up indicator
The disc was btnEnabled: false with a comment saying neither engine had a seam
on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row
dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio
(/radio/artist) — and that is what this restores. The smart arm already
shipped behind Discover's spotlight RADIO card and simply was not wired here;
the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album
radio uses, because its track objects are just as bare).

Both labels already exist in all eight catalogues, so no new msgid.

AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write;
until the first track resolves nothing on screen moves, so the click read as
dead. QbzCircleAction gains the `loading` arm the reference already has
(CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the
radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins
only the station that was clicked instead of all six.

The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no
partial redraws, so a continuous animator presents the whole window at display
rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the
seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per
~30 Hz tick, and the Connections is disabled unless the button is spinning and
on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a
RotationAnimation and is deliberately not reused.

On the cards, `loading` also pins the hover disc up: it only exists on hover,
so it vanished the moment the pointer left after a click, which is what made
the click look swallowed.

A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty
result all clear it. A disc spinning forever is worse than no indicator.

cc
2026-08-19 23:55:07 -06:00
vicrodh e816436fd0 feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.

Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:

1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
   table. Slint can derive it because its entries carry the tab
   (Favorites { tab }); here an entry is a bare route id, so a derived scope
   would read "library" for all five Library tabs and pour the Albums grid's
   offset into whatever tab mounted. Letting the reporter name its own scope
   buys tab granularity for free, and the mismatch case degrades the right way:
   an unmatched scope restores NOTHING instead of restoring the wrong thing.

2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
   contentY to 0 whenever its model is replaced, and several pages assign their
   model from a binding whose inputs arrive over the network after the mount —
   the album page rebuilds listCells from three deferred rails, so it hands its
   ListView a new array up to four times in its first seconds and snaps back to
   the top over a restore that already ran correctly. The offset is held and
   re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
   drag or a flick raises `moving`, and from then the position is theirs).

Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.

cc
2026-08-19 23:54:46 -06:00
vicrodh d1d96e6918 fix(qt): the sidebar playlists menu stops going translucent under ambient
Sidebar.slint:920-922 gives that panel the sidebar's OWN ambient treatment
(surface-card at 50%), and the port copied it. That made it the one menu
surface in the app that reads through the artwork while the dynamic background
is on. Every other menu and flyout paints flat and opaque: QbzContextMenu and
therefore CardMenu, SidebarRowMenu, ViewModeMenu and the audio flyout, plus
NavFlyout, SidebarFolderFlyout, QconnectFlyout, GenreFilterPopup and
LocalFilterPopup. Swept all of them; this was the only outlier.

A menu is text being read against art that moves underneath it, not chrome, so
the outlier loses.

cc
2026-08-19 23:53:35 -06:00
vicrodh da8fc598f8 fix(qt): the immersive trigger only moves right on macOS
9c47d5b2e moved it unconditionally, and it had no business moving on Linux. The
whole reason for the move is that on macOS the band's left edge is the native
traffic lights' corner (x ~ 7-85, the pill's exact spot). Nothing else has
traffic lights, so nothing else had the problem — the control just relocated on
platforms nobody asked about.

Gated on `QbzShell.isMacos && !QbzShell.systemTitleBar`, the same predicate the
view's old `chromeLeftInset` used, so the trailing position appears in exactly
the configuration that motivated it. Everywhere else the trigger is back at
x = 0, where it has always been.

Positioned with `x` rather than swapped anchors: toggling between anchors.left
and anchors.right means clearing one with `undefined` on every flip, and this is
one expression either way.

Built and smoked on Linux (gate green). macOS behaviour is unchanged by this
commit — it was already the trailing one there.

cc
2026-08-19 18:42:26 -06:00
vicrodh 3366cc3997 fix(qt): Metal sides with OpenGL on the RHI mirror, not with Vulkan
The rule was derived from two data points and generalised into a negation:

    owner's Vulkan + mirror=false -> upside-down   (Vulkan needs the mirror)
    Xvfb Mesa GL   + mirror=true  -> upside-down   (GL does not)

"mirror on everything EXCEPT OpenGL" fits both, and it was wrong about the one
API nobody had run. On the Mac mini M2 the Line Bed comes out upside-down, so
Metal wants mirror=false like OpenGL rather than =true like Vulkan — the
generalisation reached a third platform on the strength of a shape it had never
been tested against.

Kept as a NEGATION with Metal excluded rather than rewritten as "Vulkan ||
D3D". Naming the APIs that DO mirror would silently stop mirroring on any enum
this file failed to list: an unknown member is `undefined` in QML and compares
false, so a typo or a future backend would degrade to "no mirror" with no error.
This way only Metal's behaviour changes and every other backend keeps exactly
what it was verified with.

All four RHI scenes carry the line, because the convention belongs to the
graphics API and not to the scene — a per-scene split would be the surprising
result, not the expected one. Only LineBed made it observable: it is the one
directional scene, so a flip reads instantly, where Plasma and TunnelFlow are
near-radial and hide it. RIBBON IS THE ONE TO CHECK NEXT — it is a spectrogram
with a time axis, so if the convention really is per-API it should have been
flipped too, and if it now looks flipped the rule is per-scene after all.

The three sibling headers said "mirror on Vulkan/Metal/D3D" and were corrected
in place; they had become comments that contradict their own code.

Built and smoked on Linux (gate green). The observation this fixes is the
owner's, on the Mac.

cc
2026-08-19 18:11:37 -06:00
vicrodh 9c47d5b2e7 fix(qt): the immersive view trigger moves beside the window controls
It sat at the band's LEFT edge, which on macOS is the traffic lights' corner —
x ~ 7-85, the pill's exact spot. The band was therefore being inset sideways to
make room for one 44px control, and that inset is what read as wrong: the shell
HeaderBar's rule is right for the shell, where the lights head a full toolbar
row, and wrong here, where the row is three floating glass pills over artwork.

Moving the pill instead of the row is the smaller change and the better one. The
left half of the band goes empty and the lights get that corner to themselves;
the search field is CENTRED on the band rather than laid out after the trigger,
so nothing else shifts. Every other platform is unaffected — the trigger simply
changed sides.

Its right margin RESERVES the capsule's expanded width (112) plus a 12px gap
rather than anchoring to the capsule's live left edge. The capsule grows
leftward on hover, so anchoring to it would slide this pill sideways every time
the pointer crossed the other one — motion with no meaning. Reserving the
maximum keeps both still. The radius becomes height/2, the same silhouette as
the capsule it now sits next to.

An earlier attempt dropped the WHOLE band below the lights; it is reverted here.
It moved the search field and the capsule down to solve a problem only the
trigger had.

The capsule stays. Worth recording why, since it looks redundant on macOS and is
only half so: its fullscreen toggle writes `Window.visibility`, which the green
light already does — but its X calls `QbzImmersive.open = false`, which EXITS
IMMERSIVE, and no traffic light can do that. It is one of three exits (Escape
and the player bar are the others) and the only visible one in the chrome.

Built and smoked on Linux, built on the Mac mini M2. The macOS geometry needs
eyes on a Mac.

cc
2026-08-19 16:31:16 -06:00
vicrodh d1fd99f947 refactor(i18n): qbz-i18n owns its catalogues; macOS immersive band drops below the lights
QBZ-I18N WAS NOT FRONTEND-AGNOSTIC, whatever its own header said. No slint or
tauri entry in its Cargo.toml — and eight `include_str!` reaching into
`crates/qbz-ui/translations/`. Deleting the Slint crate, which is planned before
the Qt release, would have failed to compile `qbz-i18n` and with it `qbz-qt`,
i.e. the entire app, over a data directory nobody read as a dependency. The
extraction that created this crate took the code and left the data behind.

The eight .po files move to `crates/qbz-i18n/translations/` (git tracks them as
renames, so history follows) and the includes become `../translations/`.
`qbz-ui/build.rs` borrows them back through `../qbz-i18n/translations` for as
long as that tree exists — the direction of the dependency is what matters, and
it now points the way the deletion needs.

MACOS: THE IMMERSIVE BAND GOES BELOW THE TRAFFIC LIGHTS, not beside them. It
was inset to their right with the shell HeaderBar's rule, which is right for the
shell — the lights sit at the head of a full toolbar row there — and wrong here,
where the row is three floating glass pills over artwork and a 44px pill parked
against the lights reads as debris.

Vertical clearance comes from `SafeArea.margins.top`, the same question the left
inset asks in the other axis: with ExpandedClientAreaHint the client area covers
the titlebar region and SafeArea reports how much of it is unsafe, with a 28px
floor for a 0 reading. The band keeps `leftMargin: 24` on every platform, which
puts the trigger's leading edge in the same 20-24 band the lights start in, and
the 16px gap under them is the band's own margin reused rather than a new
number.

The view trigger matches the cluster's width on macOS (SafeArea left margin,
78 floor): stacked, a 44px pill under a ~78px cluster reads as misaligned even
with the left edges lined up. Its radius becomes height/2 rather than a constant
18, or the widened pill would have been a rounded rectangle beside three
circles. Every other platform keeps the natural 44px, and the glyph was already
centred so it needed nothing.

The menu opens downward from a band that now has more room under it, so the
flyout has more clearance than before rather than less.

Built and smoked on Linux (gate green) and built on the Mac mini M2 (3m32s, no
errors). The macOS geometry itself needs eyes on a Mac.

cc
2026-08-19 16:03:02 -06:00
vicrodh 931cb06d38 feat(qt): ease the visualizers on the pulse, and make Plasma react
TWO FINDINGS, both of which falsified something that was being assumed.

FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100
frames/s — qt-batches confirmed it, and the script now prints the pulse it ran
with so two logs cannot be confused — and the bars looked exactly as they had at
30. They would: every published frame was applied as a STEP, so the extra
presents repainted the same discrete values three times over. What the Slint
build does differently is not rate, it is `animate bar-h { 90ms }` — it moves
BETWEEN values.

So the interpolation is back, ON THE PULSE, and it costs nothing. The old
interpolator was expensive because of its private 16 ms Timer, not because it
interpolated: it presented at its own rate, unsynchronised with everything else.
Easing on the pulse presents exactly as often as stepping on the pulse did — one
repaint per period either way, only the value differs. Self-parking survives: a
converged ease writes nothing and schedules no repaint. The price is lag, and it
is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching
the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing
already in the chain.

The from/to/progress triple is gone with it, and its consumers moved to `cur`.
Note for the next reader: `SpectrumBand` drove all three of its modes from
`Connections` on those properties, and a Connections handler naming a property
that no longer exists is NOT a compile error — QML warns at runtime and simply
never fires. That is how the whole band went dead at once; grep for
`on<Name>Changed` when removing a property, not only for direct reads.

PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones
travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were
published, parsed and stored, and never handed to the item — the shader declared
them and received zeros — so everything ran off the energy bands, which decay
x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms).

Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so
above ~3 hits/s it never falls far enough to come back as a spike and settles
into a shallow ripple around 0.7. A splat multiplied by it becomes a steady
glow, and the busier the music the flatter the picture — the most frenetic input
produced the least motion. `beatAc` subtracts a slow density floor, so a hit
reads as a hit at any tempo.

The scene itself was rebuilt around the reference the owner filmed (Butterchurn
on a percussion-heavy track). Its recipe is three parts: one thin annulus
injected per frame that the feedback warp marches inward, so the concentric
rings ARE the last N frames of audio laid out in space; retention high enough
for that to stack (this held 0.85, where a trail died in ~15 frames and nothing
ever accumulated); and angular structure from reading the spectrum BY ANGLE.

But a CLOSED ring was wrong, and the owner named why: a perfect inner
circumference reads as a drawn shape, hard-edges the field, and at this
retention successive rings pack into one solid disc — which is what killed the
ambient feel. The annulus is gated by angle now, so a hit paints arcs the
conveyor carries inward, the circle never closes, and the gaps stay open for the
background. Noise bends, frays and thins those arcs so they are made of plasma
rather than of compass.

The worms are back at full strength with tighter cores and a contrast curve — a
raw gaussian is all shoulder and reads as haze — and their orbits now reach past
the arc band instead of being trapped inside it. Colour comes from ONE
album-derived triad, so everything used to be a shade of the same hue; a
Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart
whatever album is playing. That wash is what stops the background being void for
the foreground to be a cut-out against. A soft limiter replaces the hard clamp:
at 0.945 retention a clamp pins the middle to white and the structure vanishes.

Release build clean, offscreen smoke green, 421/421.

cc
2026-08-19 13:53:57 -06:00
vicrodh 730ae6998e feat(qt): windowed View-all artwork, a real per-rail default, Play later, MyQBZ tabs
VIEW-ALL PAGES GOT THE LIBRARY > ALL TREATMENT, in both halves.

PlaylistBrowse windowed nothing: every card mounted, and a View-all of
playlists runs to hundreds, each a PlaylistCard carrying a four-tile collage.
AlbumCollection had solved that for the album pages and this one never got it.
The band is sampled on a timer rather than bound to contentY — a direct binding
re-evaluates every delegate on every scroll frame — and out-of-band cards keep
their footprint as bare Items, so the scroll geometry and the infinite-scroll
trigger never move.

Covers arrived in ONE batch: Rust downloaded every missing cover of the page and
then republished the whole document to attach the paths, so a page had no
artwork until it had all of it, and the republish rebuilt every delegate on
arrival. Both browse pages now ask for what is near the viewport and take the
answers one key at a time through the existing sidebarArtworkWindow ->
libraryArtworkReady seam. `attach_card_art` stays — it is a pure disk-cache
lookup, so anything already downloaded still renders on the first frame with no
request. The two now-dead republish helpers are gone with it.

The AlbumCollection change is ADDITIVE for its nine hosts: those that pass no
`flick` never report, the map stays empty and `artOf` falls through to the
`artPath` they already publish.

ITEMS PER CAROUSEL now has a real default instead of a lie. The first entry read
"All", which was wrong twice over: an "all" in Qobuz terms is what the View-all
page holds, and /discover/index hands each rail a different count anyway —
measured on a live response, new_releases 26, ideal_discography 26, playlists 20,
most_streamed 15, and qobuzissims / album_of_the_week / press_awards 10. No
single label could be honest about all of them, so the default became a real
uniform cap of ten and the entry says so. That is a behaviour change and not
only a label one: the rails that used to arrive at 26 now arrive at 10, and the
select is how a rail gets more. Ten therefore stops being a separate option
because it IS the first one, and the number comes from Rust so the label and the
cap cannot drift. The select also went 82 -> 124 wide: "Default (10)" elided to
"Defaul…" in both the closed control and the popup.

PLAY LATER on the album page's track rows. It was off because
`enqueue_album_track` had only two arms — "next" and "everything else appends" —
so "later" and "queue" both plain-appended and offering both would have been two
menu entries doing one thing. The third arm lands on the manual block's tail via
`add_track_later`, which is the whole distinction, and the QConnect route stops
collapsing "later" into "queue" (route_track_to_peer has had a real later arm
all along). Only then does the entry turn on.

MIXTAPES <-> COLLECTIONS as a tab bar, the Discover/Library pattern where the
tabs stand in for the page heading. They are separate ROUTES sharing one
component, so a tab click navigates rather than flipping a local property: the
sidebar highlight, the back stack and the `kind` Binding then agree on where the
user is, and the page transition is the one every route change gets.

Eight locales. Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 13:53:30 -06:00
vicrodh e9f09a59a4 feat(qt): opt-in — a click on a section row opens its first tab
Settings > Appearance, OFF by default. With it on, clicking Discover lands on
Home and clicking Library lands on All, instead of only opening the section's
flyout and waiting for a pick.

The rule lives in NavFlyout because the catalog does: "the first tab" is
`entries[0]`, and routing it back through `activate` means the My QBZ section —
whose entries are plain views carrying no tab — needs no special case. Both
hosts call it from their row's click next to the open they already do, so the
sidebar and the title bar cannot drift.

The pref rides `settingsJson` like the two Purchases toggles beside it, so
nothing is wired beyond the write and the republish every settings toggle
already does. The read is a guarded parse: a bare JSON.parse in that binding
throws on the pre-publish frame and would take the whole nav catalog — and
therefore both hosts — down with it.

Eight locales. Release build clean, offscreen smoke green.

cc
2026-08-19 09:34:05 -06:00
vicrodh 81e172457f fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a
manual refresh — while the plays were sitting in the database the whole time.
Read off the live file rather than reasoned about: `playlist_play_events` held
3, 9 and 10 rows for three playlists, so the context edge was working perfectly;
`playlist_meta` was empty, and its `.schema` said why — no `covers`, no
`own_image`.

`covers` and `own_image` were added by inlining them into the CREATE. `CREATE
TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who
had run the previous build kept the six-column table, every upsert failed on
"no such column: covers", and the rail JOINs meta against events — so it
rendered its empty state with the evidence one table away. The failure was
invisible by construction: swallowed into a warn, and the UI's empty state is
also its correct state for a user who has played nothing.

A brand-new table is only new ONCE. The moment a build runs anywhere its schema
is legacy and every later column is a migration, so the pair moves to the same
idempotent ADD COLUMN the album history already uses, and the upsert's failure
becomes an error rather than a warn. Verified against a copy of the real
database: the columns land and all 22 events survive. A regression test builds
the exact pre-covers schema, records a play into it, migrates, and asserts the
upsert works and the old event still counts — it reproduces the bug without the
fix, and it also asserts a second `init_schema` is a no-op, since every launch
calls it.

The meta is written by `playlist_qt::load`, so the three playlists already in
the events table surface as soon as each is opened once.

BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader:
Binding loop detected for property source". `_stampRoute` runs inside the
`source` binding, and it read the last path from a `property string` and then
wrote it — a binding depending on a property it mutates. QML detects the cycle,
warns, and stops re-evaluating, which is a broken router and not merely a noisy
log. The holder becomes an object whose MEMBER is mutated: no change signal, so
the dependency is satisfied once and never fires again.

(The second warning on the same file, the `when` loop on the nav-flyout
`activeTab` Binding, is untouched by any of this work and predates it. Left
alone deliberately — it is a warning on a working feature, and rewriting that
Binding is its own change.)

ZEBRA on the playlist detail's track rows, like the local album page. TrackRow
already owns the stripe, so it is one property.

Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 09:01:32 -06:00
vicrodh 907d366d71 fix(qt): Library artwork loaded one row; add Refresh content to the configurator
LIBRARY ALL / ALBUMS LOADED EXACTLY ONE ROW OF COVERS and then nothing until
the user nudged the scrollbar. The grid and the list reported their artwork
window on contentY, model, width, visible and completion — but NOT on height,
and their height is gated on `visible` (`height: visible ? parent.height - 16 :
0`, the 2026-08-17 change that stops a hidden body from refilling). QML gives no
ordering between "the `visible` change handler runs" and "the `height` binding
that depends on `visible` re-evaluates", so when the handler wins the reporter
reads height 0 and computes `lastRow = ceil(0 / cellHeight) + 1 = 1` — a window
of exactly `cols` items. One row. Nothing fires again until a scroll, which is
why moving the scrollbar "fixed" it.

Playlists were unaffected and that asymmetry is the tell: PlaylistCollage
dispatches its own urls per delegate, independent of the window, while album
cells live off `artMap`, which only the window report fills. The control case is
in the same tree — LibraryAlbumsList, the playlists list and the Artists rail
all report on height and none of them has the defect. The two bodies that lack
the handler are exactly the two whose height is gated on visibility; `width` had
one because width always changed, `height` never did before the gate.

REFRESH CONTENT in the Discover configurator. The index is fetched once per
shell entry (`HOME_LOADED` latches and `reload_home` is never called again), so
a session shows the page it opened with — no new catalogue rows, and none of the
plays made since, because the recently-played rails are built from the local
history in that same pass. Everything else in that modal re-renders from the
cached candidates, which is precisely what cannot produce fresh ones. The
invokable already existed for the error-state retry; this is the door. The Reset
button's markup became a shared FooterButton so the pair cannot drift.

cc
2026-08-19 08:37:44 -06:00
vicrodh f0d753a751 fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of 26ceeab82.

TAB SWITCHES DID NOT FADE. The page fade hangs off the router's Loader
`source`, and a tab switch inside a view never touches it — so Discover's four
tabs, Library's and Local Library's cut hard while a route change faded, which
is the same event from where the user sits. `activeTab` is the one name every
tabbed view uses for this (the nav-flyout Binding already routes through it),
so one Connections in the router covers all of them; `ignoreUnknownSignals`
leaves the untabbed views alone. No watchdog and no opacity assignment there: a
tab body is built synchronously in the same turn, so `restart()` alone — which
sets the page to `from` — is the whole transition. The `running` guard keeps a
tab stamped right after a route change from restarting that route's fade.

RECENTLY PLAYED PLAYLISTS DREW PLACEHOLDERS. The rail published only
`artwork_url`, taken from the detail page's `cover_url` — and that field is
empty for every playlist WITHOUT a graphic of its own, which is most of them
and is exactly why the detail page falls back to a collage. A local playlist has
no Qobuz graphic at all, so it could never resolve one. The meta store now keeps
the member covers and an own-image flag beside the url, and the card gets both
arms it already knows how to draw: contain-fitted single graphic, or the mosaic.
All of it rides `art_url` and lets `attach_cached` fill the path —
`artwork_qt::cached_path` already classifies http, Plex and on-disk paths, so a
custom cover and a local file resolve through the same pass as a Qobuz url.

ITEMS PER CAROUSEL WAS ONE NUMBER FOR THE WHOLE PAGE. Tauri's was per rail
(`HomeSettingsModal.svelte`), and the global was a simplification that lost the
point of the feature: a 25-wide "New Releases" beside a 10-wide "Recently
Played" is why anyone opens it. Storage is now a row per section — absent means
the default, and setting a rail back to the default DELETES its row, so the
file only carries what was actually changed. The select moves into each row of
the configurator, and the cut moves into `order_by_prefs`, keyed by the PREF id:
`mostStreamed` resolves to a per-tab variant candidate, so the section's own id
is not always the id the user configured, and that loop is the only place both
are in hand.

OPEN OVERLAYS LET THE WHEEL THROUGH. A MouseArea consumes presses and NOT wheel
events, so with the configurator open the pointer still scrolled the page
underneath — over the panel it moved the panel's own list and the page behind it
at once. These overlays are plain Items rather than Popups, so nothing grabs for
them and each has to stop the wheel at its own surface: the configurator, the
genre popup, the playlist-category popup and the Local Library funnel.

Release build clean, offscreen smoke green, 421/421 and 215/215.

cc
2026-08-19 07:34:28 -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 047db8ea9f perf(qt): recycle the album page's track list, on a uniform-cell ListView
DID NOT DELIVER ITS GOAL. Read this before extending it: the wheel scroll on
a 189-track album is essentially unchanged. The page is correct and the
structure is better, but the theory that motivated the work is not supported.

What the work assumed: the ceiling was the `Repeater`, which has no windowing
and so built all 189 track blocks — each a Column with a disc-header Loader, a
work-header Loader (both gated on DATA, never on the viewport, so every act and
scene header in a Wagner box was alive) and the band-gated row. Roughly a
thousand structural items before counting anything visible.

What it is now: one ListView over a UNIFORM 10px cell tape. Each piece of
content owns a run of cells, its visual item lives in the first cell of the run
and paints over the inert ones — 50px row = 5 cells, 40px header = 4, rail =
29. Uniform cells are the point: measured in a standalone probe, a ListView
with variable delegates ESTIMATES contentHeight and ignores an explicit
override (5703 then 5396 against an exact 5400), which makes the scrollbar
thumb resize and skip as sizes are discovered. With uniform cells,
count * 10 is exact from the first frame, so the scrollbar the owner had just
called the best gesture on the page keeps its behaviour.

Falling out of the design, replacing machinery that was reverted a commit ago:
`cacheBuffer` incubates upcoming delegates asynchronously in idle time, so a
heavy rail is built before it is visible without a hand-rolled band, a settle
Timer, or an unload grace — and without the "reach the bottom, then wait" that
made the owner reject that approach.

Two correctness items that came with it:
  * `header` is a ListView property. The page's own `header` had to become
    `albumHeader` or every `header.x` binding inside the view would silently
    resolve against the view's header instead. One handler was missed in the
    rename and QML refused to create the component at all — the whole page
    rendered black, and no static gate caught it because the failure happens at
    instantiation.
  * `recycleActive` on the shared TrackRow, default true, gates the row's
    Connections. Without it a pooled row keeps listening and consumes a cache
    or favourite settle signal meant for a different track. Every other host
    is unaffected by the default.

WHAT THIS RESULT MEANS, for whoever picks it up: delegate count and
construction cost are NOT what makes this page feel heavy. Removing ~950
structural items changed nothing the owner could feel, which points at
per-frame cost instead — consistent with his own observation that the page is
sluggish ALWAYS, not only while it loads, and with the documented finding that
this shell's bill is full-window presents rather than scene work. Do not
re-attempt a structural rewrite here on intuition. Take the QSG_RENDER_TIMING
split (polish/sync vs render) on this exact album first; the earlier reference
scene measured polish=0ms, render=2ms, swap=12-24ms.

Design and probes came from an independent review; the rebase onto the current
tree, the black-screen fix and the API verification are mine.

cc
2026-08-18 23:28:25 -06:00
vicrodh 3fdccae22e Revert "perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll"
This reverts commit 5b99a6698.

The gating worked and the numbers were real — the scrollbar drag went from the
worst gesture on the page to the best — but it bought that with a delay the
owner will not take: once you reach the bottom, the three rails now take a
visible moment to appear, which never used to happen. The build has to wait for
the flick to come to rest (that wait is what fixed the wheel-scroll spike the
gating itself introduced), so "arrive at the bottom, then wait" is inherent to
this design, not a tuning mistake.

Reverted to a known-good baseline rather than tuned, because the replacement
makes it moot: converting the page to a ListView puts the rails in `footer:`,
which the view instantiates as it approaches — near-viewport building without a
hand-rolled band, a settle timer, or an unload grace.

Kept in the history rather than dropped: it is what established that the rails
were fully built on mount whether or not anyone scrolled to them, and the
"builds synchronously in one frame, on the GUI thread" lesson is what the
ListView work has to respect too.

cc
2026-08-18 22:18:44 -06:00