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

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

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

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

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

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

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

428/428.

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

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

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

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

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

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

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

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

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

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

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

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

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

cc
2026-08-19 13:53:30 -06:00
vicrodh b136c9cf5d feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).

The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.

km
2026-08-13 13:01:32 -06:00
vicrodh 5130e872e7 feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km
2026-08-03 13:32:35 -06:00
vicrodh 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
2026-08-01 15:29:03 -06:00
vicrodh 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
2026-07-31 01:33:02 -06:00
vicrodh 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
2026-07-30 18:41:06 -06:00
vicrodh 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
2026-07-30 15:19:46 -06:00
vicrodh acf23abc9b feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.

Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.

QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.

Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.

Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.

SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.

Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.

cc
2026-07-29 21:23:45 -06:00