Commit Graph
11 Commits
Author SHA1 Message Date
vicrodh 750c59034b feat(qt): Excel-style multi-select — Shift ranges across every surface
Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.

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

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

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

Three judgement calls, stated because they are not forced:

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

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

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

cc
2026-08-22 02:03:10 -06:00
vicrodh 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 313a7e7e4e fix(qt): the local album page gets its zebra, across the whole row
The shared TrackRow has had a `zebra` flag all along, off by default, and the
Qobuz album page turns it on. The local one never did — and turning it on at
the call site would not have been enough either, because LocalTrackRow wraps
the shared row without forwarding the flag.

Forwarding it is still not the fix. TrackRow tints its own background
rectangle, which is `parent.width` wide, and this wrapper hands the shared row
`root.width - 26 - 46`: a 26px gutter for the source glyph and 46px for the
context menu the wrapper draws itself. A stripe painted from in there stops
72px short and leaves both controls outside it — which is what the smoke
caught. The stripe is therefore painted by the wrapper, full width, declared
before the shared row so it lands underneath and takes no pointer events.
Same colour literal and same parity rule as rows/TrackRow.qml, so a local list
and a Qobuz list stripe identically.

cc
2026-08-18 22:15:33 -06:00
vicrodh 47a7641fc2 fix(qt): bound the queue panels, and make the gate catch what it missed
Follow-up to 66ba12bd5, which fixed the coverflow but left two of its
neighbours and a gate that could not see either of them.

QueueTabsPanel built one ImmQueueRow per queue track when opened —
including every collapsed 0-height history slot, which on the reported
session was 1162 of 1935. The slot stays (its height is what makes the
collapsed-history arithmetic work); the row inside is now built only
within a viewport of the visible band.

SuggestionsPanel and AppShell's QueuePanel move behind Loaders. The
queue panel was never the freeze — its rows are paginated at 40 in
Rust — but a closed panel should not be constructed, and the audit
cannot know about a cap that lives on the other side of the bridge.
Gating it also let SuggestionsPanel drop the onVisibleChanged half of
its load hook: mounting IS becoming visible now.

The audit is the real content of this commit. Written narrow — only a
model fed by unbounded user data counts, because the wide rule reports
145 sites and a gate that noisy gets switched off in a day — it then
reported the pre-fix tree CLEAN. A gate that cannot catch the bug it
was written for is worth nothing, so it was tested against that tree
and fixed on two counts:

  - CROSS-FILE. The `visible:` sat in ImmersiveView.qml while the two
    Repeaters over the whole queue lived in CoverflowPanel.qml. There
    is now a transitive index: a component is heavy if its own file has
    an unbounded view or if it mounts something that does.
  - MULTI-LINE. The coverflow's gate spans two lines, so a first-line
    read saw `QbzShaderScene.scene === 0`, matched no known gate, and
    skipped it.

With both, it finds all five mounts on the pre-fix tree, including the
two that froze the app. Known gates also grew past tab predicates: the
coverflow was keyed on QbzImmersive.mode.

Verified on a copy of the real session state with the 1935-row queue
restored: launches to a painted Discover, idle at 1.0% CPU.

cc
2026-08-18 07:48:56 -06:00
vicrodh 7d98ad044f feat(purchases): the Qt frontend — list, album detail, routing and gating
Two screens, a controller and a bridge. `QbzPurchases` publishes one JSON
document per screen plus a revision counter, so QML re-parses even when the text
is byte-identical.

- The list: two tabs with per-type counters, a server-side search on a 300 ms
  debounce, and a toolbar that DIFFERS by tab — Albums gets group, sort,
  direction, two filters and grid/list; Tracks gets group and the filters only.
  Building the Albums toolbar on both would have invented two controls.
- The detail: a format dropdown whose selection re-scopes every downloaded mark,
  the progress section and Add-to-Library together; a determinate bar counted in
  TRACKS, never bytes; goodies as an album-level action counted separately.
- The album download is the frontend loop, not the Rust command the reference
  registered and never called. Sequential, concurrency 1, cancellable, and a
  failed track does not abort the album.
- Both prefs are read at runtime, not merely written. `show_purchases` is the
  master gate and `nav_tb_purchases` only relocates the entry to the title bar;
  both default false, so Purchases still ships hidden.
- Scanned local rows keep their raw `qobuz_purchase` source so the gold badge
  survives in list mode as well as grid, while still classifying under the
  Offline source filter. Remote feed rows deliberately stay `qobuz`: stamping
  them would badge every purchase the user merely OWNS as locally downloaded.

Defects fixed after the screens were written, none of which any test could have
caught:

- `QbzPurchases.boot()` was called nowhere. Without the Qt-thread hop every
  publish is a silent no-op, so both screens would have stayed permanently empty
  with nothing logged. The cause was an ownership gap rather than the code:
  `Main.qml` belonged to no lane because the plan listed the files each lane
  CREATES rather than the files it TOUCHES, and "belongs to nobody" is
  indistinguishable from "done" until something runs.
- A second download of the same album nested inside the first. The store rewrites
  its destination to the album subfolder after the first successful track so that
  Add-to-Library adds the album rather than the whole music root, and the download
  planner read that value first — producing `.../Album [q]/Artist/Album [q]/...`,
  one level deeper each time. The picked ROOT and the resolved ALBUM FOLDER are
  now separate fields with names that cannot be confused, with a regression test.
- A tab counter could stick at zero forever with its list plainly full: the two
  ids calls each swallow their error, and the reference's backfill from the list
  response was missing.
- Cancelling made the whole progress block vanish instead of showing the
  cancelled state, whose msgid was already translated in all eight catalogs.
- Add-to-Library re-walked the entire library when the folder id it needed was
  already in hand.
- A live language switch and a logout left the previous state behind.

Deliberate divergences from the contract, each recorded with its reason: the page
header is a plain `fontSection` title with no icon tile, because nothing else in
this application announces itself over a large coloured square; purchase download
status travels inside the album document rather than on the shared cache channel,
which is keyed by track id alone and would let the two features overwrite each
other on the same row.

Gate: release build clean, QML audits 278 files / 37 bridges, `cargo test -p
qbz-qt` 398 passing, offscreen smoke green with zero QML complaints.

Not verified, and cannot be from here: every populated screen. This account owns
nothing, so only the empty states have ever rendered.

cc
2026-08-16 11:54:05 -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 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
2026-07-31 22:10:19 -06:00
vicrodh 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
2026-07-29 10:30:43 -06:00
vicrodh 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
2026-07-28 18:12:31 -06:00
vicrodh 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
2026-07-28 16:23:23 -06:00
vicrodh 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
2026-07-28 15:25:00 -06:00