750c59034b04cfb5010fffdc033e245b776f7c93
22
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
47a7641fc2 |
fix(qt): bound the queue panels, and make the gate catch what it missed
Follow-up to
|
||
|
|
e779b6ec1e |
fix(qt): a pulled track's menu offers nothing that leads nowhere
Smoke found the row rendering correctly and the menu still offering four entries that cannot work on it. Add to mixtape and Add to playlist are refused, in both row implementations. The precedent was already there -- `hasPlaylistAddSeam` excluded a broken local ref for the same reason -- this is the same idea with a different cause. Gated on `pulled` rather than `pulledDead`, so a downloaded copy is refused too: what a playlist or mixtape stores is the Qobuz catalog id, which is dead on every other device, every other surface and for anyone the list is shared with. The local copy playing here does not make the row safe to seed elsewhere. Both share links go too. Qobuz's page for a withdrawn track is gone and Song.link resolves through it, so each one rendered and led nowhere. Track info as well: it needs the catalog record the pull took away. That empties the menu on a surface that does not offer the repair -- an album page, search results -- so the row menu no longer opens when its model comes back empty. An empty popup reads as a broken control rather than as "there is nothing to do with this row". The ArtistView fork keeps its go-to entries and can never reach that state. cc |
||
|
|
7780efa5db |
feat(qt): mark tracks Qobuz pulled, and keep them out of the queue
Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier, sometimes it republishes the same recording under a new album id. The row keeps appearing and simply cannot play. Until now the user found out by clicking it. The app already knew and threw the answer away: eight builders hardcoded `streamable: true`, so the API's own flag never reached a row model. The reactive half was already here and stays -- a terminal failure still toasts and skips, bounded at five consecutive, with the 403 back-off kept separate so a 403 storm cannot burn five good tracks. Detection has two clauses. The API's `streamable: false`, and a session-only memory of tracks that died terminally under the player this run: /album/get is honest, but plenty of rows only fail at the stream-url step, and that memory is what stops the queue re-offering them. Session-only on purpose -- the reference kept the equivalent set in localStorage, never expired it and never revalidated it, so a track Qobuz restored stayed dead forever. Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag, favourite and download, and STAY visible and right-clickable: hiding them would defeat the point and remove the only route to a replacement. Qobuz's own player does exactly this, and the pulled track carries displayable:false while still being rendered -- that flag is not a hide instruction. A downloaded copy is exempt everywhere. The offline tier resolves before any availability question, so a pulled track already on disk plays fine; dropping it from the queue would take away something the user downloaded, and it would bite hardest offline. Those rows say they are playing the local copy and keep their play affordance. Two seams filter, not one, and both had to learn the surviving anchor: every caller read `first_id` from the UNFILTERED list, so the seam dropped that row and the app then asked the player for the id it had just discarded -- silence, no toast. That was already reachable today through the artist blacklist. The seams now return the surviving start and its id. Two id bugs surfaced on the way and are fixed here. A playlist row's `playlist_track_id` was filled with the CATALOG id; they are different numbers for the same row, one naming the recording and the other its slot in that playlist, and Qobuz's delete and reposition verbs both take the membership id -- so "remove from playlist" was addressing a row that does not exist. And the For You rows were built with `..default()`, claiming availability while the queue built from the same track read the truth. Catalogs: the two strings this needed already existed in all seven translated locales and are reused; the skipped-tracks toast is a plural pair, and the replacement feature's strings ride along here since they share the files. cc |
||
|
|
eb80e2f367 |
feat(qt): animated now-playing indicator in track rows
Restores the eq-bars indicator behind the existing play-indicator-animation pref (the toggle already lived in AppearanceSettings; the pref was plumbed but unconsumed). The bars ride the shared shell pulse, so they cost zero extra presents — the affordable version of what used to be a display-rate animation. Pref OFF keeps the static accent pill + ring, per TrackPlayCell.slint. km |
||
|
|
83b8bd8890 |
feat(qt): offline downloads + multi-select on the album page
Two AlbumView parity features sharing the row machinery: OFFLINE CACHE (the menu rows and the per-track state the report was missing): - offline_qt.rs activates the per-user OfflineCacheState on login / session-restore / offline-session (one fan-out point in bind_per_user_stores) and tears it down on logout — same on-disk store as the other frontends, so copies made in Slint/Tauri show here and play here. - playback_qt's play funnel now passes the active cache + the row sink to play_track_resolved (it passed None, None: the offline tier was unreachable even for downloaded tracks). - offline_cache_qt.rs ports the action set: cache_track / cache_tracks / cache_album / redownload_album / remove_cached / refresh_cached, with toasts and a bridge-signal row sink (trackCacheStatusChanged). - TrackRow's download column is live: cloud / spinner / accent check / red alert per status (0/1/2/3/4), click map ready->remove else download, and the context menu's offline block (Make available offline / Refresh / Remove). Status is seeded per row from the session cached-id set (album/artist/label/for-you docs) and pushed live over the signal. - Album ⋯ menu: "Make available offline" swaps to "Refresh offline copy" on the live all-cached aggregate. MULTI-SELECT: the toolbar toggle is live; selection state stays in QML (LibraryView precedent), rows check off in select mode, the bulk bar carries the reference's full inventory — select-all, play-next, play-later, queue, add-to-playlist, add-to-mixtape/collection, Add to Library, make-offline, clear — and the Ctrl+A/Escape router duck-types the view's selectAll/exitMultiSelectMode like the other tabbed views. Still out (documented in place): the Offline Manager view, the row padlock on unlock, per-disc actions, playlist-level cache button. km |
||
|
|
c198afb33c |
feat(qt): queue drag-and-drop insert at position
Dragging rows onto the queue panel now lands them AT the pointed slot (insertion line included) instead of appending: add_track + move_track compose the position locally (QueueManager has no insert-at-index and the manual-block adds would corrupt manual_next_count), and the QConnect controller path routes through CtrlSrvrQueueInsertTracks' insert_after so the position survives to the peer. Multi-row drops keep their own order. Dropping onto an EMPTY queue raises a one-shot 'start playing?' prompt rather than hijacking a running queue or leaving the user one click short. cc |
||
|
|
a16cf56c86 |
feat(qt): playlist manager, folders, importer and the Library parity round
Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc |
||
|
|
acf23abc9b |
feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
|
||
|
|
a3b89107fc |
fix(qt): table headers line up, the mini rail is centred, tooltips have a background
Three of the owner's visual reports, one theme: geometry that was derived in two places and drifted. The track table laid its header out from the LABELLED columns only, so the heart and the overflow menu took no space there and every label sat right of the column it names. Header and rows now come from one shared set of widths, including the responsive arms -- if the header hid columns on a different condition than the row, the drift would come back only on narrow windows. The collapsed rail goes back to Slint's 64px. It had been 50 by owner request, and the measurement showed why that was the actual defect: the rail centred its row at 25 while the header's own sidebar toggle, sitting directly above it, centres at 30. Symmetric padding inside a 50px rail still reads as misaligned because the eye compares the glyphs to the header control, not to the rail; chasing it from inside bought the alignment at the price of a visibly asymmetric hover pill. At 64 both references land 2px apart and the padding is symmetric again. The row stays a literal 34 rather than being derived from the width -- deriving it is what coupled the two tokens and would have silently grown every row to 48. Tooltips: the port had none, while Slint has a shell-level overlay. This adds the overlay rather than a Popup per call site. cc |
||
|
|
81c43a114e |
fix(qt): glyph colours follow the theme, on light palettes and on accent fills
Two defects with one cause: a tint name that means a frozen hex. "primary" is the legacy alias of literal #ffffff, not Theme.text-primary, so every control passing it drew a white glyph. On the eleven light themes that is white-on-light -- the header back/forward chevrons, the sidebar toggle and the carousel arrows the owner marked. They now pass the theme-following names, which icon_tint_qt bakes at runtime. The stale `hslLightness > 0.5 ? "primary" : "black"` workarounds that predate the runtime tinter collapse into those names, and the qrc floor underneath them becomes polarity-aware: dir_for() returns "" whenever the cache dir is unavailable, a bake fails or the theme document has not been published, and that path used to resolve textPrimary straight back to the white bake -- reproducing the bug it was fixing, app-wide, in the degraded state. On accent fills the fix is a deviation from Slint, approved by the owner after measurement. A fixed white glyph on Theme.accent falls under 2.6:1 on 16 of the 35 shipped palettes, including high-contrast at 1.70:1 and wcag-dark at 1.82:1 -- the two themes whose reason to exist is contrast. Slint hardcodes #ffffff there (CircleAction.slint:72 and three others), so the bug is upstream. accentText alone is not the answer either: it is picked for the theme's text ramp and gives 2.56:1 on Rose Pine Dawn, worse than the black it replaces. So QbzTheme picks the best-contrast tint by real sRGB luminance against the live accent, and the labels beside those glyphs use its colour twin -- BrowseGenreButton and LibraryView were drawing a white glyph next to an accentText label inside the same pill. Rounds out the two parity items the same files owe: QbzNavButton gains the ambient arm from Carousel.slint (wired at the six carousel sites, not left dead), and QbzCircleAction rounds its glyph to whole pixels the way CircleAction.slint does. cc |
||
|
|
a1c771c39e |
fix(qbz-qt): row buttons, cover triggers, light-theme contrast, flyouts, genre filter
**Track-row buttons did nothing.** Declaration order is z-order in QML, and the row-wide MouseArea added for right-click was declared last — so it covered the heart, cloud and menu buttons and swallowed their presses. propagateComposedEvents cannot rescue that: a press is not a composed event, which is why only the row's own double-click still worked. **Covers appeared only after a scroll.** Three compounding defects, not one. The window report fired on contentY and on model change, both of which happen while the tab body is still hidden behind its loading flag, so the report that mattered hit the not-visible early return and was discarded with nothing to re-fire it. The debounce reset on every change, so a burst of mount-time reports kept pushing its trailing edge out. And a single pending slot meant that when two surfaces are mounted at once — the artist rail and that artist's grid — the second overwrote the first, so one pane was never requested at all. Eviction had the same shape: each report deleted everything outside its own array, so the two panes deleted each other's covers. **Latest release had no cover** because the artist page's collector skipped it, along with Appears On and the playlists strip. **Light themes.** The mechanism is Slint's, from 2.0.2 and issue #638: the on-surface arm on circle actions, header-light deciding the header foreground only when a dark backdrop is actually painted, and the circular backing behind the track-row play glyph. Ported as specified rather than approximated. Two icons rendered in their SVG's own colour because they pointed at the raw asset instead of a baked tint — a sweep found the rest of that class. **Navigation flyouts** exist, one component serving both the sidebar and the header nav modes. **Filter by genre and the Discover sort now work**, everywhere the control is drawn. The genre ids were already a parameter on the discover fetch that the port always passed empty. cc |
||
|
|
9ab3d7db96 |
fix(qbz-qt): local albums route to the local view, and the detail-page parity round
**The empty album view.** Opening a local album from Library "All" landed on a blank page. The mixed feed hands Qobuz and local albums to the SAME card, and the card always called the catalog open — so a local id, which is a group key or a filesystem path, went to /album/get and came back 404. It was in the owner's log before this round as `get_album(/mnt/nas/…) returned 404`; what changed is that clearing the stale document turned "shows the previous album" into "shows nothing", which is how it surfaced. Routing now happens in open_album itself, using the predicate the feed already uses to route favourites, so every call site is covered at once instead of card by card. **Header atmosphere.** Both detail pages get Slint's two-band gradient, tinted from THIS page's artwork through the ported header_tint — not the now-playing ambient triad, which is a different subject with a different algorithm. **Artist page.** The catalog/library toggle existed but was broken: a hand-rolled copy of the shared tab bar whose delegate walked the wrong parent chain, so it threw on every evaluation and never took its active colours. It now mounts the shared control. Added the JUMP TO label, the tab-strip search with the client-side filter it drives, and the year under release-section albums. The Network panel ran the full window height and never auto-opened; it is bounded below the tab strip now and opens unless the content is constrained, as Slint does. The action circles were the wrong size with the wrong glyph colour and no overlay arm — which would have left them dark-on-dark once the gradient landed. **Quality in track rows.** The column rendered a bare tier; it carries the badge now, in the one shared row component every surface mounts. playlist_qt was the only producer not emitting the bit-depth line. **Skeletons hand over instead of disappearing.** They cleared when the PATH arrived, but the image still had to load and rasterize after that, so the placeholder vanished and left an empty cell. They now wait for the image to be ready. **Local covers.** Most local covers ARE already the scanner's thumbnail, but re-hashing their path produced a different filename, so each one was decoded, resized to its own size and re-encoded into a duplicate before being shown. That was most of the wait. cc |
||
|
|
f3a33245b2 |
feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before publishing anything, so the tracklist waited on an external service plus MusicBrainz. It publishes in stages now — header and tracks first, then each Qobuz rail as it lands, then Last.fm — with every patch merged under a generation counter so a late reply for album A cannot paint onto album B. The artist page was already staged this way; its views were the part still showing bare "Loading…" text. **Skeletons as the standard.** One control, new variants rather than new components, mounted across the views. Its second job is the one the owner named: cleanup. A cover that never resolves settles the placeholder once and reveals the card's own empty state instead of shimmering forever — local albums with no embedded art produce no artwork entry at all, so a placeholder gated only on "not arrived yet" would never stop. **Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid was one batch emitted after the whole window resolved. Each cover emits as it resolves now, visible band before prefetch margin. **Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke where the document carries it, the surrounding-line dimming, the controls flyout and the translation layer the v10 backend already supported. Exactly one row subscribes to the 30 Hz output; the timer drops to 250ms when paused or hidden. **Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal swipe, matching #653 — including the part that matters, which is that a swipe over a carousel scrolls the carousel. The layer is declared first in the shell so everything else gets first refusal. **Context menus.** Brought to the Slint inventory, Play later included, with right-click opening the same menu as the button everywhere. Entries whose seam does not exist are hidden rather than shipped inert. Two controls that rendered and no-opped are now honestly disabled: the My QBZ nav row, which routes to a view the shell has no case for, and three sidebar menu entries with no backing call. The sidebar caret was never a condition bug. The row is a positioner and the name cell's width ignored the count label, so a folder WITH content pushed the chevron past the clip — which is why it vanished exactly when it mattered. cc |
||
|
|
ade156a72a |
refactor(qbz-qt): split QbzAlbum and QbzArtist out of the God-object
Two singletons rather than one merged object, per the owner: each maps to the view it backs. Registering them and deleting the duplicates from bridge.rs is one change, because wiring one without removing the other compiles fine and leaves two independent states with the view frozen and nothing in the log. view_param_id goes with them. It had no consumer in QML, verified, and none in Rust once the two opens stopped writing it — so it is deleted rather than carried into a new bridge for symmetry's sake. bridge.rs is down to three domains: settings, search and playlist. cc |
||
|
|
5c631000b7 |
refactor(qbz-qt): split QbzLibrary out of the God-object
library_bridge.rs has sat in the tree unreferenced since the phase-23 work stopped: no mod declaration, not in build.rs, zero call sites, and duplicating members that were still live in bridge.rs. It could not have been wired as it stood — its module was named qbz_library, which is ambiguous with the qbz-library crate this binary depends on, so it never compiled. The module is now qbz_library_bridge; the QML type name comes from the QObject, so QML is unaffected. Registering it and deleting the duplicates had to happen together. Wiring it alone would have compiled cleanly and produced two independent Library states — QML reading one object while Rust published to the other — with the view simply frozen and nothing in the log. Moved with it: four properties, five invokables, three signals, the seven library-only ui() hops in main.rs, and thirty-four QML references. The Connections blocks in LibraryView, Sidebar, QueuePanel, AlbumView and PlaylistCollage retarget wholesale because every handler in them is a library signal. ArtistView's does not: it mixes onLibraryArtworkReady with onReleaseSectionReady, so it becomes two blocks — retargeting it whole would have orphaned the releases pager, and QML resolves handlers lazily enough that the discography would just have stopped loading with nothing in the log. bridge.rs is down to 389 lines and five domains. cc |
||
|
|
0799d59e01 |
feat(qbz-qt): Local Library — folder tree, windowed album grid, detail
The sidebar has had a "Local Library" row since the shell was ported and nothing behind it: the nav entry did not even navigate. This builds the view. QbzLocal is its own domain bridge (the phase-23 pattern, one #[cxx_qt::bridge] per domain) over local_library_qt, which consumes qbz-library for the scanning, the folder-vs-tag album identity and the queries rather than reimplementing any of it. The view carries the folder tree, the album grid, album detail, the track tabs, sorting and search, plus the scanning and empty states. Windowed from the start, not as a later pass: real local libraries reach 16k tracks and that is exactly the surface where the Slint port freezes. Artwork is id-keyed and evicted with the window. Playback is source-aware — a local track plays from disk through the player's play_data seam, because the Qobuz tier-walk needs a client and fails on every local advance. TrackRow gains an opt-out for its drag source: a local row's id is a local database row, and the sidebar's drop handler forwards whatever it receives to `playlist add-tracks` as a Qobuz catalog id, so dragging one onto a playlist would have silently added an unrelated track. Not included, and tracked as follow-ups: Plex union and thumbnails, multi-select and the bulk bars, ephemeral folders, the tag editor, A-Z jump strips, the group-by dropdown, the tree's resize handle, per-tab scroll restore, and local favourites on rows. Album detail renders inside the view rather than as its own routed page. cc |
||
|
|
9a4fec6b70 |
refactor(qbz-qt): phase 23 part 1 — QML module layout + first five domain bridges
Two fronts of the modularization, both gated green before this commit. QML leaves the flat qml/ directory for seven modules: cards, controls, rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml stay at the root. build.rs matches the tree exactly (55 files), relative imports and asset paths were re-anchored for the new depth. The QbzBridge God-object starts breaking up: QbzSession, QbzShell, QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge] singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted in order from Main.qml. Bridge files stay directly under src/ because cxx-qt-build only accepts rust_files from one directory (QTBUG-93443). bridge.rs keeps 5.5 domains (library, album/artist, settings, integrations, search, playlist) and dies once they are split out. cc |