c807d24463c11f98b3348e39abd7e917bb3f4f8f
39
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
28c04bd990 |
docs(qt): correct the stale search notes
The two whose falsifying commit does not touch their file, so R7 rule 2 could not put them beside the code. QbzEmptyState claimed it had consolidated SearchView's and Cortinilla's no-results states. Neither uses it - both draw their own line inline, and the cortinilla's is a single 28px row inside a dropdown rather than a centred block. SearchView's SOURCE note justified its Qobuz-only assumption by saying the local sections "are not ported". They are now. The assumption still holds for the results PAGE, which is Qobuz-only, so the note is narrowed rather than deleted - the cortinilla is a different document whose rows carry their own source field. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C12 (R7-7, R7-8). cc |
||
|
|
4806584ee2 |
feat(qt): local "View more" routes into the LocalLibrary tabs
The three local sections' View more links now leave the search surface entirely and land on the matching Local Library tab. Sending them to the Qobuz results page instead would show a page with no on-device content at all, which reads as the link having done nothing. local-album -> Albums tab local-artist -> Artists tab local -> Tracks tab, pre-filtered with the live query The query pre-filters the TRACKS tab only. Albums and artists have no search box of their own, so handing them a query would be a lie - the list would come back unfiltered while something claimed otherwise. The active tab is QML-owned state, so the route is a pending property the view consumes, mirroring the localPendingArtist mechanism that already existed for "go to artist" on a local album. It is released after being applied, because the property CHANGE is the trigger and an unreleased route could not fire twice in a row. The consumer also runs on mount, so a route set while the view was unmounted still lands. Setting the tracks filter writes the view's own search state as well as issuing the query, or the search box would render empty next to a filtered list. Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C9 (sections 3.4-3.5). Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke 0 complaints + home published. cc |
||
|
|
fa433d465b |
refactor(qt): extract search_bridge.rs from the QbzBridge god-object
QbzBridge carried five domains (Settings, Search, Playlist, Genre filter,
Discover config); its own boot() comment called the split "TEMP (phase 23)".
TRACK-RULES section 2 requires one cxx-qt bridge per domain. This moves the
Search domain out verbatim, ahead of the cortinilla parity work, so the
refactor and the behaviour change stay separately reviewable.
QbzSearch (search_bridge.rs) takes 7 properties and 11 invokables. Two
deliberate changes ride the move:
- selected_index -> cortinilla_selected_index. It is cortinilla-only
state and the bare name on an app-wide singleton is a collision hazard.
- cortinilla_scroll_y f32 -> f64, unifying with the immersive twin in
immersive_bridge.rs, which was already f64.
cortinilla_query is declared here but not yet wired; C2 wires it, which is
what makes Enter carry the text the user actually typed instead of the last
successfully loaded payload's query.
Every Rust writer of a moving property was re-pointed BY HAND, never by
regex over the multi-line ui() closures: the seven crate::ui( sites in
search_qt.rs plus two that live outside it and that the QML-only xref audit
cannot see - settings_qt.rs (the live Settings > Appearance path) and
main.rs toggle_intelligent_search. All 48 QML consumer sites across
Cortinilla.qml, HeaderBar.qml and SearchView.qml were updated, including the
one Connections block, whose onSelectedIndexChanged handler was renamed with
its property.
A stale 6.0 base in flat_index_content_y is left in place and annotated: it
mirrors a Flickable viewport margin rather than content space, so every row
reports 6px low. C4 corrects it with its unit test; this commit is a move.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C0.
Gate: build clean (no new warnings), 256/256 tests, qml_resolution_audit
214 files, qml_singleton_xref 28 bridges with QbzSearch resolving, offscreen
smoke 0 complaints + home published.
cc
|
||
|
|
5130e872e7 |
feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km |
||
|
|
ec8588687b |
feat(qt): artist page parity - load more, section sort, discography
The release-section "Load more" fetched a page and appended it to a structure
no view ever re-read. The handler pushed IN PLACE into the object behind
`readonly property var artist: JSON.parse(QbzArtist.artistJson)`, and the
releaseSections projection returned that same array on the unfiltered arm, so
both Repeaters were re-handed the identical reference and nothing was dirty.
The tree's own rule, written in cards/PlaylistCollage.qml: rebind requires a
NEW object reference. A second bug sat on the same path - the artist document
republishes progressively (stories, then each MusicBrainz section as it
resolves) and every republish re-runs the parse, wiping anything appended, so
fixing only the first would have left a bug that looks random. Both
pre-existing; the in-flight placeholder added earlier is what made an
always-silent no-op visible.
Appended pages now live in an overlay keyed by release type. It survives a
republish, emits new arrays so the Repeaters rebuild, dedupes by album id,
carries the reference's 4-page cap per bucket (artist.rs:718-720, which hands
off to the discography page beyond it), and is guarded against a page
requested on one artist landing on another's grid - releaseSectionReady
carries no artist id and every artist has an "album" bucket.
"See discography" was a pointer-cursor MouseArea with no onClicked under a
POC-NOTE saying the page was out of scope. ArtistReleasesView.qml +
artist_releases_qt.rs port ArtistReleasesView.slint: infinite scroll rather
than a button, plus the loading, error-with-retry, empty and offline arms.
The album page's "More from <artist>" view-all is a second door to the same
page. Its paging offset counts what the SERVER returned rather than the
rendered row count the reference uses: blacklist filtering and id dedupe make
the rendered count smaller, so the reference re-requests seen rows and skips
the tail whenever the blacklist is non-empty.
The per-section sort was a dimmed Rectangle with no MouseArea, under a
comment asserting no seam existed. It is now a real QbzSelect with the
reference's five options and index mapping, persisted per release type in
artist_prefs. Choosing "Default" after a sort does not restore the server
order until the page reloads - that is the reference's behaviour
(album_map.rs:262 is a bare no-op arm and resort sorts the live model), kept
deliberately rather than diverged.
Also, two visual fixes from the same round that share these files: Popular
Tracks drew its cover at radius 8 where the shared rows/TrackRow.qml uses 4,
and the album page's rail mounts said `width: parent.width` inside a Column
with 32px padding - QML padding shifts children without narrowing
parent.width, so the rails hung 32px off the right and the right page chevron
landed outside the window.
Carries scripts/qt-diag.sh, which has been sitting untracked since the
rendering round: it answers "which backend is this actually running on" from
a real graphical session, refusing ssh and refusing offscreen because either
would answer the wrong question. That refusal is the point - the doctrine it
disproved ("shader effects render nothing on this port's software path") came
from an offscreen observation, and offscreen forces software by definition.
cc
|
||
|
|
edbc037b0f |
feat(qt): Rust share seam, and fix the dead label share link
The port had no clipboard seam at all. share_qt.rs ports the URL builders and the clipboard from crates/qbz/src/share.rs:1-70. The long-lived OnceLock<Mutex<Option<Clipboard>>> is copied verbatim with its reason: arboard ties the offer's lifetime to the LAST live Clipboard object, so a create-per-copy port passes on KDE, which runs a clipboard manager that accepts the handoff, and silently loses the text everywhere else (#514). arboard carries the wayland-data-control feature at the same pin the reference uses; without it arboard is X11-only and every copy fails on a Wayland-only session. The label header's Share was not merely silent - it copied a link that does not resolve. open.qobuz.com has no /label/ route. The host is per-ENTITY, not per-app: track and album on open.qobuz.com, artist, playlist and label on play.qobuz.com. That asymmetry is why the five builders are ported as a set and why no call site formats its own URL any more: this one did, and got it wrong. QbzHome::label_share now copies play.qobuz.com/label/{id} and raises the "Link copied" toast, 1:1 with main.rs:13164-13178, and the QML Loader/TextEdit clipboard hack it replaced is deleted - its own comment claimed there was no toast seam, which stopped being true when toast_qt.rs landed. Only the URL builders and the clipboard are ported. share.rs:72-184 is the Song.link/Odesli resolution, which applies to tracks and albums; artists and labels are link-only, so porting it now would be dead async HTTP. LabelView also adopts QbzLoadMore here - its two changes share the file. cc |
||
|
|
6f4fb72f57 |
feat(qt): shared QbzLoadMore with an appended-page skeleton
Five hand-rolled "Load more" buttons existed in three visually distinct shapes. One control now carries all three arms, so the idle appearance of every site is unchanged. While a fetch is in flight it draws a QbzSkeleton composite BELOW the button - the owner asked for below - which is one animator for the whole placeholder row, with settleMs 8000 so a fetch that never returns stops shimmering instead of pulsing forever. Idle it costs nothing: the block lives behind an inactive Loader, so mounting the control changes no pixel on a page nobody has clicked. SearchView had half of this already, and the half it had was on the wrong side: a skeleton ABOVE the button, on the albums tab only. All four tabs now use the control, each with its own real cell pitch and row shape rather than the albums tab's numbers copied across. AlbumCollection gains an opt-in tail fade (armTailFade) for the arrival half of the round. It is keyed by the set of ids on screen at click time, not by an index threshold: the label page sorts its grid, and under any order but newest a fetched page interleaves, so a threshold would re-fade albums that never moved - the flicker the fade exists to avoid. It also lets grouped mode fade, where there is no flat index at all. Inert unless a host arms it, so Discover Browse and Play History are bit-for-bit unchanged. cc |
||
|
|
8776ebe82d |
fix(qt): carry the real track data into every queue, and shuffle for real
Two defect classes the owner found by playing music, both of them the port diverging from a reference that gets it right. QUEUE TRACK FIDELITY. The reference has ONE builder, make_queue_track (playback.rs:2426), and it takes the raw Track, so bit_depth and sample_rate come straight off the API model. This port forked that into ten builders and five of them mapped from a DISPLAY ROW instead — a row that carries qualityTier / qualityDetail / qualityLabel, the already-formatted strings, and no raw numbers. Those five hardcoded bit_depth: None, sample_rate: None: playlist_qt, artist_qt, label_qt, library_qt and playback_qt's feed_queue_track. The consequence reached the user as a broken NPB AudioStamp: the track edge seeds quality_state from those same numbers, so starting playback from a playlist, an artist page, a label page or a Library-feed row left TRACK_MAX at zero and the badge could not render a complete bit-depth / sample-rate line. Starting from an album was perfect, which is what made it look intermittent — the album path was one of the five that had always built from the raw Track. The rows now carry the numbers alongside the strings, because the producer derives the strings FROM those numbers and was discarding them; parsing "24-bit / 96 kHz" back into integers would have been a second source of truth. The same builders were dropping artwork_url, which is why the queue sidebar had no covers for playlist-sourced or local rows. Also fixed here, found while tracing: local_playlist_qt::play shipped context: None, so set_queue_stamped fell through to derive_context, which answers None for a many-album playlist. The song card then used its per-track album fallback and pointed at a local album_group_key instead of the playlist. Pre-existing for the header Play, but a guard added last round routed the row click through the same path, which turned it into a regression there. SHUFFLE. set_shuffle(true) only randomises what comes NEXT, so four entry points raised the mode and then played the list's #1 track: the album header, the playlist header, the local-playlist header and every local play_rows call. The one track the user actually hears on pressing Shuffle was deterministic. All four now mix their own list with the xorshift Fisher-Yates the port already uses in play_track_list_in and start at the top of the mixed order. The mode stays raised so playback continues shuffled past the list. The owner traced the flag-and-play-from-the-top shape to a very early Tauri experiment; it is not the behaviour anywhere. Not addressed, going to a separate pass: a Qobuz playlist carrying local or Plex sidecar rows. The reference routes those through the local snapshot (playback.rs:3488 is_mixed); this port has no sidecar concept on playlist_qt at all, so such a row is still typed as qobuz. Same defect class, one layer up. cc |
||
|
|
35508b5f87 |
fix(qt): the defects found smoking the playlist and My QBZ round
Un-following a Qobuz playlist left it in the sidebar and in My Library. toggle_follow called unsubscribe_playlist and settled the ownership snapshot and stopped there, so the row only left at the next full refetch. Both surfaces are patched directly now, in both directions: following from a card buys the metadata with one get_playlist rather than re-reading getUserPlaylists, which lags the write. The un-follow collapses a hearted-and-followed playlist to the one row the next load will actually produce instead of dropping it, because the local heart is a library.db flag that survives the unsubscribe — removing every row made it vanish and come back, which is the original complaint wearing the other shoe. The Plex mark on album cards was a hard-drive glyph tinted accent — a blue hard drive — and the three Qobuz kinds drew no badge at all. The badge now ports discover/AlbumCard.slint:326-354 exactly: 24x24 at radius 4 pinned bottom-right with a 6px inset, plex-logo untinted at 16, qobuz-logo at 18 (22 for a download), the purchase arm gold on its amber chip, hard-drive white at 14 for local only. The brand marks are plain Images: routing them through QbzIcon floods every pixel with the tint and flattens a logo to a silhouette. SourceIcon moves to controls/ since it is no longer local-only, and every other surface drawing a hard-drive was audited against its .slint. The local album detail loses the two nav chevrons above the art. They are a Tauri-era leftover from before the app had global navigation. My QBZ used a bespoke card in the grid. MyQbzDetailCard is deleted and the standard cards/AlbumCard is mounted with its overlay. localMode bundled four separate things — action routing, the heart, the pin and Block this album — so a multi-source container had to switch all four off together; it is split, and the catalog affordances now follow the ITEM. On a Qobuz cell the heart works through the shared favourite seam; on a track, playlist, local or Plex cell it is absent rather than dead. Row covers carry two rungs: the list keeps _50, the grid gets _600. Not the _230 that would merely cover a 200px card — the complaint was that the cover reads softer than a normal AlbumCard, and a normal AlbumCard is fed image.large, which IS _600. _200 is not a rung the CDN serves at all (it answers 404). The My QBZ grid sets reuseItems, and AlbumCard assigns to its own isFavorite and isPinned when toggled, which destroys the binding that fed them. A recycled delegate therefore showed the previous row's heart and the click added the new album while the glyph said remove. Restored with the Qt.binding idiom this port already uses on TrackCard, PlaylistCard and TrackRow. The details view no longer force-opens every row on entry, and the per-row open state persists. Library > Artists showed placeholders for nearly every portrait. The side panel never reported its artwork window — the one caller of libraryArtworkWindow is LibraryView.reportWindow, and the panel had no report call at all, so no key ever reached the resolver and artMap stayed empty for its rows. The rail now reports through that same path, with the section-header sentinel reportWindow already skips. Note the reference has no portrait fallback either: an artist Qobuz serves no image for is a placeholder on both sides. 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 |
||
|
|
d1862e90da |
fix(qt): the Source column draws the real Qobuz and Plex marks
Owner: "si es de qobuz, icono de qobuz, si es de local (folder o plex) cada uno tiene sus svg; Slint y Tauri los tenían bien." They did. The port did not, and the reason was an asset gap rather than a styling choice: `qobuz-logo-filled.svg` and `plex-logo.svg` were never carried over from the Slint tree, so `SourceIcon` substituted `cloud-download` for every Qobuz kind and a tinted `hard-drive` for Plex — drawing "downloaded" and "local file" where the design calls for two brand marks. Both assets now live in `qml/assets/brand/`, beside the Discogs / Last.fm / MusicBrainz marks that are there for exactly the same reason. The rule this restores is one `SourceGlyph.slint:4-8` calls load-bearing: the full-colour logos render UNTINTED and only the monochrome local glyph is tinted, to MUTED text and never accent. Slint spells out why — routing a logo through a tinting primitive "would turn the logos into a flat accent silhouette, the bug this primitive replaces". `QbzIcon` floods every pixel with its tint, so the two logo arms deliberately do not go through it. `qobuz_purchase` is the one arm that must tint a multi-colour asset (the same mark in #eab308 — the colour is what says "purchased"), so it is the one arm that uses an effect, and it degrades on a software renderer to the untinted mark rather than to nothing. The two ROW call sites now pass SourceGlyph's own numbers (15px, muted) instead of inheriting the version picker's (14px, secondary) — the two Slint components this file stands in for disagree on both, which is why they are properties. Verified by driving the app: the Qobuz rows show the Qobuz mark and the Plex rows the gold Plex chevron, in their own colours, with zero "Cannot open" in the log. cc |
||
|
|
83c1aec4f5 |
feat(qt): collapsible rows in the My QBZ detail, and ephemeral leaves no trace
**The accordion the owner asked for.** Each expandable row gets a chevron; clicking it opens THAT row's tracks inline and nothing else. Verified by driving the real app: in list mode every row is collapsed with the chevron pointing right, clicking row 3 — a PLEX row, so the hybrid path — flips its chevron, renders its three tracks with durations and quality badges, and leaves rows 1, 2, 4 and 5 untouched. This is a DELIBERATE divergence from both references, decided by the owner with the facts in hand: neither Slint nor Tauri has a per-row accordion. Both render inline tracks only as a view MODE, for every row at once, and both say "no chevron" in their own comments (MixtapeDetailView.slint:1139, MixtapeCollectionDetailView.svelte:124-127). The owner first believed Tauri had it, asked for the code to be checked rather than the comment, and on the evidence chose the accordion anyway. Do not "restore parity" by removing it. Built additively on machinery that already existed: the document already carried `canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`. What was added is per-row OPEN state in Rust, a toggle invokable, and lazy per-row fetching — opening a row fetches only its tracks, once. The "expanded" view mode survives and now means "open all", sharing ONE notion of open rather than a second parallel state. Also here: - `body`'s `anchors.bottomMargin: 100` shrank the list VIEWPORT; in the .slint that 100px is padding INSIDE the Flickable, i.e. scroll runway. Measured on screen before the fix: the last row clipped mid-height with ~85px of dead band under it. - `apply_resolved` cloned and serialised the WHOLE document per resolved item (34 serialize→parse→patch cycles for one collection, 200 for a big one, on the GUI thread), and `patch_expanded` did the same while the document carried every loaded item's inline tracks — O(n²). - The DEAD affordances the audit filed against the modals and the grids. **Ephemeral honours its name** (owner rule): an ephemeral folder plays and queues, and writes nothing durable. Recently Played and Most Played are now guarded — an ephemeral album played through would previously have outranked real albums in Most Played, one row per track start. The Slint reference does NOT have this exemption; that is a gap in both frontends, not a Qt regression, and this does not restore parity by removing it. Two stores were deliberately NOT changed and need the owner's word: the scrobblers and Discord presence. Neither is "las bibliotecas" — nothing is stored on our side — but both publish the track to a third party, so if "ni now playing ni nada" is meant literally they belong in the same decision. The one-line fix location is in the lane report. cc |
||
|
|
b6cf06ff2a |
feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db opens instead of 23-34 — the local source owns a pooled handle and hands out no guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an await either. Bug 1 from the seam design is dead by construction, not by care. The owner's rules, decided today, now live in one place instead of eight: - **Collection** takes albums, singles and EPs from ANY source — qobuz, folders, Plex, and whatever is added next. **Mixtape** takes albums, playlists and tracks. This was a single boolean (`restrict_to_mixtape`) that could express neither release types nor "collections accept local". - An unsupported combination must never be VISIBLE: the picker stops offering the impossible action, and the error stays a log-only safety net. Owner: "el bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI". - **Ephemeral honours its name** — playable, queueable, and offerable to nothing else. - A row's source comes from the ROW. Eight QML sites built the add-to-mixtape payload with `"source": "qobuz"` written as a literal, so a Plex or local track was stored as Qobuz and afterwards could not play at all: the app asked the Qobuz API for an id that never existed there. `QobuzSource` reads the client through a lens on every call instead of holding a clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384, both on paths that run before `set_session`), so a cached clone goes stale and fails silently — the hazard the crate's own doc comment described. The lens is installed in `on_boot`, first of everything, and finding out why is the lesson of this commit. The gate was green — build, 142 tests, both QML audits, a clean offscreen smoke — and the feature was visibly broken: every Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE" type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever touches it first BUILDS it, and one built without the lens leaves Qobuz permanently detached. Only driving the real app surfaced it; the crate's error string named the cause exactly, which is the one thing that made it a ten-minute fix instead of an afternoon. cc |
||
|
|
ea373a601c |
fix(qt): four AUDIBLE parity rows — stream errors, seek clamp, label shuffle, Plex keys, Library queue
PARITY-DEBT #7, #15, #17, #4 and #5. - **#7** the stream error was drained and only logged, so an ALSA / exclusive failure — guaranteed inside Flatpak and Snap — froze the bar at 0:00 with no explanation. It now toasts, with the sandbox-aware rewrite the reference carries (playback.rs:5093-5115). This is the support issue that fix was written for. - **#15** seek was clamped to the full duration instead of the downloaded fraction, so the head could be dragged into bytes that do not exist. The poll loop publishes `npSeekableMax` and both bars clamp to it, matching SeekBar.slint:93-98. The cache overlay was already drawn and purely decorative. - **#17** a label page's Shuffle called `set_shuffle(true)`, turning the GLOBAL shuffle mode on and leaving it on for everything played afterwards. The reference shuffles the vector in place and never touches the mode. - **#4** a `plex:<hash>` album-boundary key was passed where a Plex rating key belongs, so the fetch 404'd and the PREVIOUS track kept playing under the new card. Guarded at both sites, 1:1 with playback.rs:668-680 (`b5c1a76e`). - **#5** clicking a track in Library played exactly one track and stopped. It now queues the whole VISIBLE list from the clicked row (`order_by_visible`). The Library row play needed one edit outside its author's lane, which the singleton xref caught before the build did: `QbzLibrary.libraryPlayVisible` was called from QML and implemented in `library_qt`, but never declared on the bridge. That is precisely the failure the xref exists for — QML resolves singleton members lazily, so a missing invokable compiles clean and throws when the user clicks. The QML had shipped a `typeof === "function"` feature-detect with a fallback, so it degraded instead of throwing; the invokable now exists and the fallback is dead code by design. cc |
||
|
|
1c2bdb749a |
fix(qt): the My QBZ detail delegates read the live row, not the model's snapshot
The whole in-place patch path was a silent no-op, and it took the Quality column and expanded mode down with it. `model: root.rows` is a JavaScript array. QQmlDelegateModel SNAPSHOTS such a model, so a delegate's `modelData` is the engine's own copy of the element, not the object sitting in `root.rows`. `syncRows` patches with `Object.assign` on purpose — reassigning the array resets contentY and throws the user to the top of a 200-item collection — and then bumps `patchRev` to re-evaluate the bindings. Every part of that worked except the last one: the bindings faithfully re-read the STALE copy. Measured before the fix, driving the real app over VNC: 34 items resolved Rust-side with correct tiers (26 get_album, all 200 OK), 34 publishes, 71 QML refreshes, `syncRows` taking the patch branch every time — and the Quality column sat on its loading dots forever, byte-identical five minutes apart. Expanded mode rendered no inline tracks at all, for exactly the same reason: `inlineTracks` is delivered by that same patch. The fix is to index the live array: `patchRev` stays the dependency and `root.rows[index]` is the object Rust actually patched. `modelData` remains the fallback for the frame where the array and the delegate model are momentarily out of step. No array reassignment, so the scroll position is still preserved. After: the badges resolve to "CD 16-bit / 44.1 kHz" and the inline track lists render with their durations and per-track badges. cc |
||
|
|
494596616e |
feat(qt): port the Blacklist manager for artists and albums
The port had a 67-line settings row showing two counters and a header saying the Manage chevron was "NOT shipped — this port has no Blacklist Manager view for it to open". The Slint has 1,114 lines of manager plus the consumer surfaces that make the feature mean anything. - src/artist_blacklist.rs is a 1:1 port of the reference's per-user lifecycle, including the fail-open asymmetry (is_enabled reads TRUE when there is no session, everything else false/empty) and the byte-identical NO_SESSION_ERR. src/reco_dismiss_qt.rs carries the reco-dismissal store. Both are bound in ALL THREE session-activation paths — the offline one included, because the reference records that Tauri never bound it offline and blacklisted artists leaked into offline surfaces — and torn down on logout, or the next account inherits them. - The manager view: three tabs (bordered, ADR-008 — no pills), search, sort, the enable toggle, the disabled warning banner, the four body branches per tab in their load-bearing order, windowed lists, and the clear-all confirm. Reached from Settings only, exactly like the Slint; no sidebar entry. - Every one of the ten store mutations publishes TWICE — its own document and publish_settings() — because the Settings counters live in a different document and nav_qt::back() re-runs no per-view load. Without it, Manage -> unblock -> Back showed pre-mutation counters. The Slint gets this free from a shared global. - The search field is driven imperatively, not by a binding: QbzLineEdit's X used to destroy the binding, after which the box went blank while the list stayed filtered. - Consumer surfaces, which is where the feature actually lives: the album card and album list row gained "Block this album", the artist card gained the 3-arg "Not interested", the album header menu gained Block/Unblock swapping on state, and the artist page gained the Show/Blacklist flip plus the amber hidden banner with its "Show artist" button. album_qt and artist_qt now seed isAlbumBlocked / isBlacklisted at document build time — without the seed an already-blocked album offered "Block this album" and the first click UN-blocked it, the same lie the pin glyph told before isPinned existed. - search_qt passed &Default::default() for both blacklist snapshots, so nothing was filtered anywhere and a greyed row still played. It now passes the real snapshots at both call sites. - The 17 toasts the spec had dropped for want of a toast host are wired: that premise stopped being true when toast_qt landed, and a Clear All that failed with NO_SESSION_ERR was completely silent. Their msgids are in all eight catalogues; the five album-axis ones existed only in es and nl. Also fixed in passing, pre-existing on this branch and unrelated to the blacklist: ArtistView's library tab read `libTracks` unqualified from a child of the object that declares it, which QML resolves against the component root, not the enclosing object — so the "Tracks" heading threw a ReferenceError and never rendered. Found by forcing the artist route in the offscreen smoke, which no earlier gate did. 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
|
||
|
|
d4667f5ea9 |
perf(qt): rounded covers use a real mask, the spectrum ticks at 62Hz, the derivative cache is bounded
The track's "ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the
software path this port runs on" doctrine was measured FALSE on 2026-07-29:
qbz-qt runs on the GPU (OpenGL RHI, threaded render loop, vsync 5.56ms). The
claim came from an offscreen session, and QT_QPA_PLATFORM=offscreen forces
software by definition. Every design it justified needed re-deciding.
- RoundedImage was a Canvas (renderTarget: Canvas.Image) that rasterised with
QPainter on the CPU AND uploaded a texture per repaint, for every cover in
the app, purely to round a corner identical in every frame of that image's
life. It is now layer.enabled + MultiEffect { maskSource } — an FBO rendered
once and cached for static content. Rectangle { radius; clip: true } cannot
do this: clip in Qt Quick is a RECTANGULAR scissor and ignores radius.
maskThresholdMin/maskSpreadAtMin are 0.5/1.0, NOT the 0.0/0.0 that looks
natural: 0.0 collapses Qt's mask smoothstep to step(0, alpha) and every
pixel with any alpha snaps fully opaque — measured 0 antialiased pixels at
radius 40 against the Canvas's 292. At 0.5/1.0 it is 308, ramp [0,51,219,255].
A software machine keeps the Canvas arm (GraphicsInfo.api), and
QBZ_QT_ROUND_MODE=canvas pins it on a GPU machine where the mask misbehaves.
- VizSettle drove the spectrum from FrameAnimation, i.e. every rendered frame:
180fps on this box for data the producer publishes at TARGET_FPS = 30. Now a
16ms Timer; the wall-clock maths is unchanged.
- SpectrumBand's per-bar y was a SECOND JS binding derived from height. An
anchor does the same arithmetic in C++ with no binding at all, and each FFT
frame is materialised into a plain array once per publish instead of being
read element by element across the JS/C++ boundary. Bindings per tick: bars
28 (was 56), waveform 48 (was 96), energy 5 (was 10).
QQuickPaintedItem was investigated and rejected with evidence: cxx-qt 0.7.3
emits X(QObject* parent) : base(parent), which does not compile against
QQuickPaintedItem(QQuickItem*), paint() is pure virtual, and there is zero
precedent for a custom base class in this workspace. Qt 6 ShaderEffect takes
no GLSL, only precompiled .qsb, which would make the build depend on a Qt
tool whose path differs on macOS.
- PlaylistCollage point-sampled its mosaic in its own Canvas
(Context2D.drawImage does not filter when it scales — measured RMSE 0.000
against nearest-neighbour). It now draws through RoundedImage.
- The scaled-derivative cache had no eviction at all and the .jpg -> .png key
change had orphaned 1251 files. Boot housekeeping sweeps the orphans once and
bounds the directory at 64 MB, LRU by mtime. Verified by effect: first run
deleted 1259 files / 10.05 MB, every run since reports 0, and the directory
is 14 MB. The eviction is only survivable because RoundedImage clears
_scaled on Image.Error and re-requests.
- For You artwork patched the rails in place instead of republishing the
document, which was resetting contentY. HomeView is destroyed on every
navigation, so the patch map is re-handed on mount through
QbzHome.refreshForyouArt() — otherwise the Radio rail and the Spotlight hero
went blank for the rest of the session after one navigation, and the skeleton
pulse Timer stayed pinned ON, defeating the fix it belonged to.
- Spotlight's placeholder gated on the local path instead of the remote url, so
it flashed a glyph on a cold cache where the Slint does not.
The retired doctrine is corrected wherever it was load-bearing for a decision.
The pre-baked icon tints are deliberately NOT reopened and no drop-shadows are
added: both are visual changes, not perf fixes.
cc
|
||
|
|
0c159c141c |
feat(qt): For You gains Qobuz Mixes pages, Artist Spotlight and Radios
Ports the three sections the tab was missing, against the Slint implementation rather than invented: the section rails, the cards, and the mix landing page with its navigation seam so back/forward behave. The mixes reuse the shared crates for their requests -- the Slint app already makes them, so nothing new talks to Qobuz here. Also carries the qt-run.sh portability work needed for the Mac mini: `free`, `nproc`, `pgrep` and `timeout` all differ or are simply absent on BSD/macOS. Two of those bit during the first Mac build. `pgrep` exits 1 when it matches nothing -- the normal case -- and under `pipefail` that killed the script before a single line of output, which reads exactly like a build that did nothing. And `timeout` does not exist on macOS at all, so the smoke produced a one-line log over which the grep cheerfully reported "0 QML complaints": a test that never ran, rendering as a pass. The smoke now fails outright on a log that short. 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 |
||
|
|
6b5226acf9 |
fix(qt): covers are actually downscaled, and non-square art keeps its ratio
The owner's clue was the whole diagnosis: the same image looked fine at 220px in the hover preview and bad at 150px in a card. So not the source, and not the URL variant either -- every Qt producer picks exactly what Slint picks, `large` (600px) into a 200px cell, 3x headroom. `artwork_qt` was producing the right derivative and `RoundedImage` was drawing the untouched 600px original next to it. `Context2D.drawImage` does not filter, so every card was a nearest-neighbour downscale: measured on a card cropped out of the running binary, RMSE 0.000 against a point-sampled reference. The preview looks right because it is a plain Image with smooth+mipmap and never touches this path. Three defects were latent behind that dead derivative: `thumbnail(w,h)` does not preserve aspect (46 square covers on disk written as 32x28), `to_rgb8` matted transparent logos onto black, and the file was JPEG q75 -- more damage than the resample it was meant to cure. Qobuz playlist art is 800x380, and both the header and the card were stretching it to fill a square. It now keeps its ratio, and the uncovered area takes a gradient derived from the image itself, reusing the ambient colour pipeline rather than starting a second one. cc |
||
|
|
d5bf4398ac |
fix(qt): Library sub-tab switch no longer segfaults, and the pane keeps its corners
Three identical faults in dmesg over three days, all `segfault at 8 ... libQt6QmlModels.so.6.11.1[420c5]`. `nm -DC` puts that offset in `QQmlDelegateModel::model()`; the QVariant return means sret, so %rsi is `this` and address 8 is a null pointer plus the member offset. `QQuickItemView::model()` reads its d-pointer, checks a QPointer guard and then calls straight through without testing the value. The re-entry is `onContentYChanged`, not `onModelChanged` as first assumed: `setModel()` emits it from inside the window where the old delegate model is already gone. Switching to Labels re-derives the model AND clamps contentY to a shorter list, which is the emit -- hence the intermittency, since at contentY 0 nothing is emitted and nothing crashes. Reproduced in plain qml6 with no app and no login, and bisected: readback in onContentYChanged dies, in onModelChanged survives 401 swaps, cached array survives everything. The fix is to stop asking the view for its own model. A tree-wide audit found `.model` is the only member that is unsafe this way in 6.11.1 -- count, contentHeight, indexAt and itemAtIndex all null-check -- and the three LocalLibrary tabs already cached their arrays, which is why this stayed a Library-only bug. Also: `clip: true` is a RECTANGULAR scissor in Qt Quick and does not follow `radius`, so the album/artist header band painted over the content pane's rounded corners. Slint clips to the rounded shape on the same element, which is why the identical structure is correct there. The mask sits OUTSIDE the frame rather than at a high z, because ADR-009 pins in-pane modals at z >= 3000 with no ceiling and a z fix would be a standing arms race. 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 |
||
|
|
c56fab3c4e |
fix(qbz-qt): covers were never actually resized, plus the adversarial parity round
**The scaled-cover fix from the last round produced nothing.** The seam ran — 462 requests in one offscreen smoke — and wrote zero files, so the owner was right that nothing improved. The artwork cache stores files as `.img`, and `image::open` picks its decoder from the EXTENSION, so every call failed silently. Sniffing the content instead yields 101 derivatives on the same run. The header atmosphere had the identical bug and had therefore never produced an image either, which is why the flat fallback was all anyone ever saw. Reported as fixed last round without checking that it ran. Verified by effect this time, not by reading the diff. The rest came through an analyse / refute / implement / verify pipeline, and the adversarial passes earned their keep: - The sidebar mini rail: icons sat left of centre because their slot kept its fixed 24px width while the rail collapsed, so the hit target was rectangular and off-axis. The rail narrows, rows are square, and the mini state gains the folder flyout and tooltips it never had. The reviewer caught a comment claiming the width change was a one-token revert when it is two — corrected rather than left to mislead the next reader. - Playback context now travels from every producer, not the handful that happened to stamp it. - Collapsible per-tab search in Library, and label artwork. - Every View-all destination Home offers, plus the label page and its releases. - Collapsing the sidebar drops a Large bar back to New, live, without overwriting the user's persisted preference. cc |
||
|
|
2a5b5ac16e |
feat(qbz-qt): theme-following icons, the real header atmosphere, sharp covers, playback context
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light themes secondary, muted and accent were simply wrong, and accent was wrong on any theme whose accent is not #4285f4. Slint recolours at render time, which this path cannot do — so the tint is generated once per colour into the cache and loaded through the same Image+svg path the bakes already use. No effect, no shader, so it cannot hit the "renders nothing on software" failure. The sweep found the part the diagnosis had missed: "primary" was two meanings welded together — max-contrast-on-theme-surface, and literal white for glyphs on hosts that are dark under every theme. Remapping it globally would have painted dark glyphs on dark scrims in fourteen places. The vocabulary is split instead, with "primary" left as an alias so nothing changes until it is migrated deliberately. **The album header gradient was Slint's fallback, not its design.** Slint paints a blurred artwork field and only falls back to the flat two-band gradient when that image is unavailable; the port shipped the fallback. The field needs no shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look, not an optimization; everything after it works from eight pixels of colour, which is why it reads as atmosphere rather than as a blurred cover. It comes out of the same decode that already produced the flat tint. **Covers were point-sampled.** Canvas drawImage does not filter when it scales, measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in a 200px cell throws away eight of every nine pixels, and the error is aliasing, not softness. Rust now produces a derivative at the drawn device size so the draw is 1:1 and there is no resample at all. **The now-playing context stack.** It had no Qt counterpart: the model carried context_kind and context_id, some builders stamped them, and nothing ever read them back. The album path only appeared to work by coincidence, because the glyph was hardcoded to open the album. Plus: Discover's missing rails with real play history, Recommendations on the integrations, queue drag reorder, and the playlist detail collage. cc |
||
|
|
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 |
||
|
|
d76f694781 |
feat(qbz-qt): close the two declared debts on the album and artist pages
The Last.fm similar-albums row exists now. The recommendation building was already frontend-agnostic in qbz-external-reco, so this only adapts QbzCore to its catalog trait and publishes the result; the row seeds on the album's artist and excludes what the two Qobuz rows already show, including ids the pre-resolution dedup misses. Resolved rows cache per album for thirty days in the same store the Discover tab uses, so re-opening an album makes no Last.fm or Qobuz calls, and only a non-empty result is cached — a transient failure must not hide the row until restart. With Last.fm not connected it resolves to nothing and makes no network call, so the rail simply never appears. Relationship rows navigate. They carry a name rather than a catalog id, so the click resolves through MusicBrainz first and only a CONFIRMED match opens an artist — landing the user on a same-name musician is worse than a row that does nothing. The role travels with the name so a match in another role is not mistaken for this one. cc |
||
|
|
960715c4ac |
feat(qbz-qt): skeleton placeholders for the heavy views
Library and Home showed grey squares that filled in unevenly, which reads as broken rather than as loading. One shared QbzSkeleton — a card shape and a row shape — now stands in while data is on its way, and each placeholder clears as its own item arrives rather than all at once, so loading reads as progressive. The animation is the part that could have undone the point of this track, so it is capped: a bounded number of placeholders breathe, and the timer stops when the view is not visible or the window is minimized. Not on focus loss — a tiling desktop keeps windows visible and unfocused, which is the rule already established for the ambient field. cc |
||
|
|
49ab5f5b9d |
feat(qbz-qt): fill the Album and Artist pages from the integrations
The two pages were missing most of what Slint shows. With MusicBrainz, Last.fm and ListenBrainz wired this round, the sections that depend on them can exist: the artist biography and network, relationship rows, listener figures and the credit blocks the Slint pages carry. Nothing is faked. A section whose data an integration cannot supply is absent rather than an empty frame, and a section fed by an integration the user has not connected degrades to absent without a network call — integrations stay strictly opt-in. The relationship rows are non-navigable for now because resolving a musician name to a catalog artist needs a bridge invokable that is not there yet; an informational row is better than a dead click. cc |
||
|
|
e0fcf7a089 |
fix(qbz-qt): local context menus, collapsing search fields, toolbar density
Four things the owner hit in Local Library. The context menu did nothing there — local rows and cards now mount the shared menu the rest of the app uses, with the action set Slint gives local and Plex items. Anything with no seam in this port (add-to-playlist, add-to-mixtape, the tag editor, local favourites) is hidden or visibly disabled rather than a row that silently does nothing. The per-view search fields did not open and collapse the way the header's does. That behaviour moved into the shared QbzLineEdit, so the Local toolbar, the queue and the tree rail all get it from one place instead of four copies. The queue's field also escaped its 300px column; it is width-bound now. Filter, sorting and grouping become icon-only buttons with an explanatory tooltip, which is what the owner asked for and what the crowding called for. Found on the way, and affecting Qobuz cards too: the album card's ⋯ menu had no "Play later" at all, and its "Add to queue" row dispatched play-later — the row lied about what it did. Both fixed, with Slint's icon set for the three. cc |
||
|
|
31b1484c01 |
feat(qbz-qt): Local Library complete — five tabs, routed album page, Plex
The view had roughly a folder tree and an album grid inside one 1,847-line file. Slint's is 2,671 lines across five tabs, and the local album detail is a routed page of its own. This closes that gap and splits the result: the view becomes a composition root over qml/views/local/ modules, and local_library_qt.rs becomes a narrow facade over local_* modules by concern. Plex is wired for real. The port hardcoded the cache path to None, so the union was local-only and Plex thumbnails were skipped. The settings store binds per user at session activation, the album and track union includes Plex, thumbnails resolve through the artwork path, rows carry their source badge, and the manual re-sync is on the toolbar behind the availability gate — as in Slint. Tabs: Albums with its quality/format/source filter popup, Tracks paginated, Folders in both flat and two-pane tree modes, Artists as an A-Z master/detail. Every list is windowed from the start, artwork id-keyed and evicted, because 16k tracks is exactly where the Slint build freezes. The tree grows a real select mode with its bulk bar, the ad-hoc "ephemeral folder" pane plays music that was never indexed, and the album page gets its version picker — a local album with two physical copies now switches between them without a database round-trip. Two agents built the QML and the Rust halves in parallel and the QML called thirty QbzLocal members that did not exist. QML resolves lazily, so each would have been a control that renders and does nothing; a static cross-reference caught them before a build ever ran, and they are implemented here. Deliberately logged seams, not half-paths: add-to-playlist, add-to-mixtape and the tag editor have no picker or modal in this port yet, and local favourites are not wired, so those actions log rather than route a local id into the Qobuz API. Plex playback downloads the part instead of range-streaming it, which is Slint's own documented fallback. 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 |
||
|
|
10461bb49c |
fix(qbz-qt): playlist artwork — own graphic for Qobuz, collage for the rest
Two bugs with one root: the card bound the playlist's `images*` lists, which are MEMBER-ALBUM covers, not the playlist's own artwork. So a Qobuz editorial playlist showed one of its albums' sleeves, and a user playlist — which has no `images*` at all — showed nothing. Qobuz playlists carry their graphic in `image_rectangle`, which the model did not deserialize; the field is added (additive, `serde(default)`, and Playlist is only ever built through serde) and the card prefers it, contain-fitted so the artwork is not cropped. Playlists without one fall back to the member-cover mosaic, reusing the collage the sidebar already draws rather than a second implementation. Both the All-tab grid and its list mode are covered — the 44px list thumb binds the same map and would otherwise have gone blank. cc |
||
|
|
aabde3be63 |
fix(qbz-qt): unresolvable QML imports, focus-based freezing, and dock render cost
Three views instantiated components the phase-23 directory move had left unimportable: PlaylistView and SearchView use TrackRow (now in rows/) and SectionRail uses AlbumCard (now in cards/). QML resolves lazily, so these survived cargo check, the test suite and the offscreen smoke — they only failed when the user opened a playlist. Adds a repo-wide audit of every instantiated component against the importing file's visible directories; the three above were all of them. The ambient field and the new dock band froze on lost focus. That is wrong on any tiling or mosaic desktop, where windows stay fully visible while unfocused — the field would stop while the user is looking at it. Both now gate on the window not being minimized or hidden, which is the real "nobody can see this". Freezing on not-visible stays. Dock band render cost, in descending order of impact: the drain publishes only the stream the active mode consumes (Bars mode was marshalling a 512-float waveform QList 30 times a second for nothing); each mode marshals its QList into JS once per tick instead of once per delegate; one shared Gradient replaces 28 per-delegate instances; and the per-bar Behaviors are gone since qbz-audio already smooths and clamps the amplitudes upstream. 18/18 tests; offscreen smoke clean; component-resolution audit clean. 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 |