0e5f160a7cf63dce9f0a28ea023027f4bed1171c
234
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
da8fc598f8 |
fix(qt): the immersive trigger only moves right on macOS
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
f0d753a751 |
fix(qt): fade on tab switches, playlist recents artwork, per-rail sizes, wheel
Four defects from the smoke of
|
||
|
|
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
|
||
|
|
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
|
||
|
|
3fdccae22e |
Revert "perf(qt): the album page's rails exist only near the viewport, and never build mid-scroll"
This reverts commit
|