750c59034b04cfb5010fffdc033e245b776f7c93
297
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
750c59034b |
feat(qt): Excel-style multi-select — Shift ranges across every surface
Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.
NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.
The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.
Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.
Three judgement calls, stated because they are not forced:
- Library gets TWO models, one per kind. Its rows are mixed, and one
anchor would let a Shift-click on a track sweep up the albums drawn
between it and the anchor.
- Ranges run over the VISIBLE rows. Local Library's search box is a
view filter, and a range over rows the user cannot see is not the
range they asked for — the same call `select-all` already made.
- The Local Library tree rail keeps plain toggles. Its selection lives
in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
an anchor off; teaching the Rust side about an anchor is its own
change, and the file says so where a reader would expect otherwise.
The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.
Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.
cc
|
||
|
|
7fc7605887 |
feat(search): the Tracks results use the app's full track row
Search already mounted the shared TrackRow, but had never switched on any of its affordances: no artwork, no artist link, no album column. So the one surface where people are most often hunting for an album gave them no way to reach it except the context menu's "Go to", three clicks deep, while every other track list in the app links straight through. Turning the arms on is most of it. The missing half was in Rust: `search_qt::TrackRow` carried `albumId` but not the album TITLE, and the row draws `item.album` as the column's label and uses `item.albumId` for the click — so without the title the column would render empty and the link would be invisible. `track.album` was already being read one line above for the id. No layout work: TrackCols already reserves the album cell and its gap behind the same `showAlbum` flag, so the widths redistribute themselves. Verified by driving the app: a search for "beatles", the Tracks tab, and a click on "Abbey Road" in a row opens Abbey Road (Super Deluxe Edition). cc |
||
|
|
cf07f10a59 |
fix(settings): make the Font row actually change the font
It shipped doing nothing. Measured on the built binary: picking a face
persisted the pref and changed ZERO of 600600 pixels in the settings
label column. The row was a control that lied.
Two measurements say why, and both were run against this Qt build rather
than reasoned about:
- `ApplicationWindow.font.family` propagates through Qt Quick CONTROLS
only. In a standalone scene with a family set on the window, a
`Label` resolved to it and a sibling `Text` stayed on the
APPLICATION font. This tree is 1082 plain `Text` items against 13
that name a family, so the app's typeface IS the application font.
- The application font is read when an item is CONSTRUCTED. Driving
PySide6 against the same Qt, `QGuiApplication::setFont` updated
`QGuiApplication::font()` and left an already-built `Text` on its old
family.
Together those close the door on changing it live, so the ordering in
main() is the feature: FontPreload.qml registers the bundled faces (a
family does not exist until its file is loaded, and `load()` returns
with its FontLoaders already in the database), the application font is
set from the persisted choice, and only then does Main.qml build the UI.
Swap any two of those and the setting silently does nothing again.
The row now says "(requires restart)", the same wording Interface size
two rows above already uses, in all eight catalogues.
"System" resolves to "" and takes no branch, so anyone who never opens
the row gets exactly the font this app rendered before — verified by
capturing the window on both settings: 0 log lines and an untouched
frame for System, 27065 changed pixels for Montserrat.
NOT ADDRESSED, and worth its own decision: those 1082 Text items have
never used the bundled Inter either. `Main.qml` has always named it, but
that only ever reached the Controls, so the app renders in whatever Qt
picks. Making "System" mean Inter is a one-line change and a visible
one, so it is left alone here.
cc
|
||
|
|
bbe9f25689 |
refactor(settings): Plex gets the same header as Jellyfin and Subsonic
The three media servers sit one under the other and Plex was the odd one out: a small-caps "PLEX" eyebrow with no brand mark in a 64px band, against the 44px SourceIcon + 14px DemiBold title the other two use. SourceIcon already draws the full-colour Plex mark untinted, so this is the same header, the same metrics and the same glyph pipeline. NOT folded into MediaServerSettings.qml: that component is one form twice — address, account, password, connect, sweep — and Plex's body is a different shape, a PIN pairing flow with a server picker and a per-library table. Only the header is the same. One affordance the other two have is deliberately not copied: the "· <server name>" suffix. PlexFields carries no name — pairing stores a url and a token — so the row would be permanently invisible. An empty binding is not parity, it is a dead branch. cc |
||
|
|
002231b46b |
fix(settings): the Library > All select is the same size as every other
`sm: true` made this the ONE select in Settings drawn at the small size, which reads as a rendering bug sitting directly under the full-size Album grouping select. The control-alignment standard has one size for a settings row. cc |
||
|
|
f50c1b5188 |
feat(settings): the library folder table becomes its own page
The folder block — toolbar, add-path row, filter, the row-per-folder
table and the scan progress — is the tallest thing in Settings and it
sat at the TOP of Local Library, pushing Albums view, Library > All,
Maintenance, Danger zone and the three media servers off the first
screen. Behind a Manage row, the whole panel fits one screen again.
The shape is the one Blacklist already uses for its manager, and this
view is deliberately its twin: same 64px summary row, same metrics, same
route-recorded-by-the-caller contract.
Nothing about the table changed. LibraryFolderTable.qml is mounted
verbatim, still driven by the settings document and still acting through
the settingsString action keys.
Two things had to travel with it, because SettingsView used to supply
them and a routed page has no access to that:
- LibFolderEditModal, or the per-folder pencil would open nothing,
silently.
- the refresh on entry. A view reached by Back runs no per-view load,
so it asks for its own, where BlacklistManagerView puts its reload.
Verified in a running window: the route mounts, the seven folders render
with their per-row actions, and Back returns to Local Library with the
section intact.
cc
|
||
|
|
e90369d931 |
refactor(settings): regroup Appearance around what each row answers
Row wiring is untouched — every block moved verbatim and nothing here
changes what a control does. What changed is the grouping, so the group
order can no longer be read out of AppearanceSettings.slint:
THEME the picker and the rows that exist only for it
TYPOGRAPHY & LANGUAGE Language, Interface size, Font
NAVIGATION was LIBRARY & VISUALS minus the visual rows,
plus the three Purchases/menu rows
SEARCH new: Intelligent Search came out of THEME,
Immersive search out of TYPOGRAPHY & LANGUAGE
PLAYER & VISUALS was PLAYER VIEWS, plus the visual rows
WINDOW WINDOW TITLE + TITLE BAR + WINDOW CONTROLS
SYSTEM INTEGRATION NOTIFICATIONS + SYSTEM TRAY
RENDERER unchanged
Four rows are now ABSENT rather than rendered-and-disabled, because a
control that cannot do anything should not be on screen: Purchases in
title bar (Show Purchases off), Window controls position (system title
bar on), Close to tray and Tray icon variant (tray icon off). Each keeps
its stored value, so turning the parent back on restores the answer
already given.
The My QBZ row loses its fixed logo preview: it renames the hub and
never picked an icon, so the image was decoration in a control column.
Verified in a running window, English and Spanish.
cc
|
||
|
|
3c4c2dae20 |
feat(settings): an app-wide typeface, picked from the fonts already shipped
Settings > Appearance > Typography & Language gains a Font row. The catalogue is the SAME five the lyrics panel offers and the same slugs, because they are the same files: everything is bundled in qml/assets/fonts/, so a choice cannot fail on a machine that lacks the face and looks identical everywhere. "System" resolves to the window default, which is Inter. It applies LIVE, with no restart: the family sits on the ApplicationWindow, which every Text that does not name its own family inherits. The index rides its own qproperty rather than the settings document, because the consumer is the root window and making it parse the whole settingsJson on every republish to read one integer is the wrong trade. Same shape as theme_filter beside it. The loaders are lazy, exactly like the lyrics picker: a family nobody selected is never loaded, and one that has not finished loading falls back to Inter rather than rendering the app in a wrong face for a frame. Only the first label is translated — a typeface name is a proper noun. cc |
||
|
|
58fa77a390 |
feat(qt): fade artwork in as it arrives
Artwork appeared in one step the instant its pixels were ready, which reads as a glitch on a page where covers land a few at a time. It belongs in this file and nowhere else: AlbumCard, PlaylistCard, TrackCard, ArtistCard, SlimCard, LabelCard and RadioCard all mount a RoundedImage, and 81 files in the tree do. Per-card animations would be seven copies that drift, and would still miss the detail headers and the rows. The trigger is `ready`, not the path and not `status`. `ready` is this file's readiness contract, MONOTONE WITHIN ONE SOURCE, which is exactly what a fade needs: the derivative swap sends `status` back to Loading mid-life, and fading again there would flash a cover already on screen. A recycled delegate does re-fade, which is correct — it is a different cover. The Component.onCompleted arm is not belt-and-braces: an already-cached pixmap reaches Image.Ready DURING creation, before onReadyChanged is connected, and without it that cover would sit at opacity 0 forever. Measured against the repaint doctrine rather than argued: at rest 0 presents/s (byte-identical frames over 3 s) during a page load Home 5.7 -> 6.0/s, Library 11.1 -> 12.4/s Qt advances every running animation on one tick and the window presents once per frame however many items are dirty, so fifty covers arriving together cost the same presents as one. OpacityAnimator, not a Behavior, so the fade keeps advancing while the GUI thread is still paying off a mount — which is precisely when covers land. cc |
||
|
|
b970e8f853 |
perf(qt): mount the settings panel in view, not all nine
The nine panels were siblings gated only by `visible:`, so every mount of Settings built the 30 KB Appearance panel, the 19 KB Plex panel and the rest in order to show one of them. Measured before the change, Settings was the slowest route in the app. A Loader with `active:` is the fix that shipped for the Discover tabs and it is the fix here. Measured after: built 326 -> 167 ms, to-idle 392 -> 224 ms. The two overlay modals (LibFolderEditModal, DacWizardModal) are still built unconditionally and are the next item of the same kind; they own their own open state, so gating them is a separate change. cc |
||
|
|
dc8cb1fa32 |
perf(qt): build only the discover rails that fit the viewport
2026-08-17 fixed the four Discover tabs, which were all instantiated because `visible: false` hides an item without stopping QML from building it. This is the same disease one level down and it is what was left: a Repeater builds EVERY delegate in its model, so a ten-section tab built ten rails, each rail's horizontal ListView then created its own delegates during the first layout pass, and the viewport has room for about two and a half. That work is what `to-idle` was made of — the rails nobody could see paying for their cards before the GUI thread could reach the event loop and let a frame out. The seed is one viewport and the rest arrive after the first frame. A rail is ~328px with the Column spacing against a ~740px pane, so three covers the fold; +2 per tick reaches even the twelve-section For You tab inside ~80 ms. The `active &&` on the visibility gate is load-bearing: a Column skips invisible children entirely, but an active:false Loader is visible and 0px tall, which would reserve a spacing slot per pending rail and collapse it as the rail lands. Honest limit: this did NOT turn out to be what fills the blank stretch between the acknowledgement frame and the content. Library has no such ramp and shows the same shape, so that hypothesis is refuted and the remainder is unattributed. cc |
||
|
|
5b274cab57 |
perf(qt): acknowledge a route change on the next presented frame
Measured by driving the real window and diffing the captured frames: a
click on Discover > Home left the window with ZERO pixels changing for
590 ms — the flyout did not even close. The click handler, the flyout
close, the sidebar highlight, the page blank and the whole mount all ran
inside ONE turn of the event loop, so the first frame that could be
presented was the one that already had the finished page in it.
The breakdown matters more than the total, because only one part of it
is what it looks like:
navigate_to (Rust, GUI thread) 7.6 ms
bridge queue hop 1 ms
the QML mount (`built`) 263 ms
post-mount work before a frame ~290 ms <-- `to-idle`
`built` is under half of it. `to-idle` is the number that matches the
freeze, and any future work here should be judged on that one.
So the route is now committed in two phases: arm on the change, build on
the next PRESENTED frame (frameSwapped). Frame N carries the cheap half
— flyout gone, highlight moved, page blanked — and frame N+1 pays for
the mount. Acknowledgement measured at 73-90 ms across five routes and
repeat runs, against 590 ms before.
Two things this had to get right, both caught by capturing frames:
- The backstop timer was a RACE at 32 ms. Render-at-next-vsync
(16.67 ms here, per QSG_INFO=1) plus a QUEUED frameSwapped delivery
lands in ~16-33 ms, so Home acknowledged at +90 ms while Library got
nothing at all on the same build. 250 ms puts the guard outside that
range; it now only covers the hidden-window case it was written for.
- During the armed frame `viewLoader.item` is still the OUTGOING view,
so the incoming route's discriminators were applied to it. The
activeTab Binding wrote "home" onto LibraryView — which has an
activeTab, so the typeof guard passed — emptying its body and
restarting the page fade over the blank. Both Bindings now require
the commit to have happened.
Not asynchronous: incubation is time-sliced and was reverted the day it
was tried; every view is still built synchronously.
cc
|
||
|
|
a11b302e5d |
chore(qt): an env-gated probe for what a navigation costs the GUI thread
The route stopwatch in ContentRouter.qml starts when QML sees the new route, so everything before that — the invokable, the prefs write inside nav_qt::record, the per-view hydrate — was outside every instrument this track has. That is not a small remainder: measured with this probe, a navigation spends single-digit milliseconds there, which is what rules the Rust side OUT as a cause of the dead click and points at the mount. QBZ_QT_NAV_PROBE=1 turns it on. Unset it costs one OnceLock read per navigation and emits nothing, the same discipline the qbz.nav.timing LoggingCategory already follows. cc |
||
|
|
2273db9184 |
feat(qt): the disc surfaces — rip wizard, metadata correction, and a pane that
can be read RIP WIZARD, three steps, each answering something the first version got wrong by not asking. What will happen — which disc, how many tracks, and that the output is FLAC, stated rather than implied. The tracks, editable, because a CD-DA carries no titles at all so "named wrong" is the ordinary case and once files are on disk it is a rename job. And what happens afterwards: a ripped album the library cannot see is a folder full of files, so the destination is compared against the registered folders — by path COMPONENTS, not by string prefix, since /music-backup starts with /music and is not inside it — and the right follow-up is offered, naming the folder it would re-scan. "Do nothing" is an explicit choice rather than the silent default. The follow-up runs only on success. Tracks can be deselected. Everything is ticked on arrival and the user goes from all to fewer; a wizard that starts empty makes the common case the one that costs the most clicks. On the Rust side an absent `selected` means yes, so an older form cannot silently rip nothing. A PROGRESS PANEL behind the ripping chip, which is a door rather than a label: it is the only thing on screen during a rip and the two questions it could not answer are the ones that matter. A soft lock says not to eject — soft because nothing here can hold a tray shut, so the honest move is to say it and keep saying it. Per-track state with a bar on the row that is actually moving. Stopping is offered, deletes nothing, and says so; a cancelled job does not jump to 100 %, which is the one lie a progress bar must not tell. CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder, which has no identity to write a correction under. The list shows what distinguishes four pressings of one album rather than four identical titles, and flags a track count that disagrees with the disc. Selecting is not applying: the candidate is fetched in full and its track list shown, because that list is the only thing proving a plausible title is the right pressing. Applying renames the live session without renumbering ids — a re-open would orphan a playing track — and writes a user row. Only MusicBrainz re-resolves the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is worse than the old one. The correction can be dropped, because without an escape a bad apply is permanent and that makes people afraid of the button. THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track set whose album_group_key is the box root for every file while the disc number sits in the data unused — so the pane was a flat list 247 rows long. The key carries the disc, unconditionally, because it round-trips through the play-album lookup. Its own client-side filter over what is already rendered, since finding one track meant scrolling past the other 246. EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published isEphemeral on every row for a while and the queue panel respected it; the bar never read it. The heart goes inert, the add menu keeps only its queue actions, and the row menu comes back with those three and nothing else. Absent rather than rendered-and-inert. Also: a spinner on Open that blocks a second click onto a drive already being read, held by a counting RAII guard because opening is a relay and the chip must not blink off in the seam; the now-playing card routes a disc to the pane it came from instead of to an album id no catalogue can open; the file pickers use rfd, the platform's own dialog, which the POC-NOTE said was a five-line change once the dependency existed — it existed, Settings had been calling it from this crate for weeks, and on macOS the four Linux choosers do not exist so Open folder and Open SACD image opened nothing at all, in silence; the empty state stands down for the Open pane, which on a machine with no library drew both fully painted; tooltips on every disc control through one hover() helper, since two call sites had already mismatched their key, which does not fail — the bubble simply never goes away. cc |
||
|
|
57ae465668 |
feat(disc): remember a disc after it has been ejected
A disc is the one medium QBZ can play that leaves nothing behind: no path, no row, no file. Everything learned about it was thrown away when the tray opened and re-derived from the network next time. That is fine for names that came from MusicBrainz and not fine for names that came from a human. One DiscID can name several pressings — this album answers with four — so the moment there is a button to pick the right one, or a wizard that lets a title be fixed before it is written, the choice has to outlive the eject. Otherwise correcting a disc is a toy: fix it, take it out, put it back, it is wrong again. A CACHE, not a second source of truth, with one rule that makes it safe: a row the user edited is never overwritten by an automatic lookup. put_auto refuses to touch an edited row and only put_user can replace one, enforced in the store rather than at the call sites so a future third lookup path cannot forget. Identity is the TOC fingerprint: pure geometry, no network, available before anything has been looked up, and identical on both platforms. It names the GEOMETRY rather than the pressing, which is the point — "for this geometry the user chose pressing 2" is the fact worth keeping. Global rather than per-user, because which record is in the drive does not depend on who is logged in. Seven tests, including the one this exists for. disc_identity carries WHICH disc is open for the two features that name the medium rather than the session; nothing there persists. cc |
||
|
|
cd8f8e6443 |
fix(qt): the CD cover arrives, and it stops blocking the disc from opening
Reported: the CD opened with its names but no artwork. The log said why, because the previous commit taught it to — "no cover art (500 Internal Server Error)" — which is a better failure than the silent one it replaced, and it pointed straight at two separate bugs. WRONG KEY. Cover art was asked for by RELEASE, and a release is one pressing. The owner's Fear Inoculum answers 500 for its release and 200 for its RELEASE GROUP, which is the album as a work and where the canonical art lives. The lookup now asks for `release-groups` too and tries the release first, the group second. WRONG PLACE. The fetch was awaited BEFORE the session was published, so a click produced ten seconds of nothing — measured: the Cover Art Archive took 9.4 s to answer, because it redirects to archive.org. The tracks now land immediately and the art is patched in when it arrives. Patching it in needs `replace_tracks_preserving_ids`: re-running `open_tracks` would renumber every row from the synthetic floor, and the queue may already hold the old ids — a playing track would lose its source mid-song. A late cover for a session that has since been closed or replaced is dropped rather than painted onto whatever is open now. cc |
||
|
|
a922bdd316 |
feat(qt): disc covers, a corner close, and an icon for Rip
Three things the owner asked for after driving the first build. COVER ART. A SACD image carries none, but the file it was downloaded as almost always sits beside one — `cover.jpg` next to the `.iso`, or one level up when the rip is split into "Disc 1"/"Disc 2" folders, which is the shape of the owner's Rheingold. `find_folder_artwork` already knows that walk, so this reuses it rather than growing a second rule about where covers live. CLOSE moves to the pane's top-right corner, for all three media. It used to sit in the action row beside Shuffle — a destructive action one pixel from "play", which is where a hand goes without looking. It is also OUTSIDE the Flickable now: scrolling a 24-track disc must not carry the only way to close the session off the top of the screen. THE RIP ICON is a disc with a folder, drawn as a Lucide-style outline rather than traced from the owner's PNG. The argument is the size it renders at: at 15px a solid, detailed glyph turns to mush while an outline still reads, and the comparison was rendered at both sizes before choosing. Adding it also found a gap in my own tooling. `icon_tint_qt.rs` re-tints from MASTERS at theme-change time, and `masters_cover_every_shipped_glyph` failed the build for a glyph that was baked but had no master — six files written, one line forgotten. `bake_icon.py` now registers the master itself, because a step that has to be remembered is a step that eventually is not. cc |
||
|
|
bbce651d79 |
feat(qt): a Rip button on an open CD, and it asks where to put the album
The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.
The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.
While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.
Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.
Four new strings in all eight locales.
cc
|
||
|
|
452589ddaa |
feat(qt): open a SACD image and play it — the disc names itself
`Open > Open SACD image…` picks a .iso, reads its stereo area and publishes it as the ephemeral session. Nothing lands in library.db. Unlike a CD, a SACD needs no network to be named: the Master TOC carries the album title and artist, and the area TOC a title per track. The owner's disc opens as "Wagner: Das Rheingold", 24 tracks, every one with its German title and "Berliner Philharmoniker, Herbert von Karajan, etc." — all of it read off the image. Routing is one line in `LocalSource::playback` and none in the player: a `sacd:` reference rides the existing DsdFile ticket, because `open_dsd` is the funnel all six player-side DSD constructions already go through. Teaching it there is what makes the PCM, DoP, native and gapless paths inherit disc images for free. Driven end to end on the owner's Rheingold: the tab reads "Wagner: Das Rheingold 24", the badges read HI-RES / DSD64, and pressing play took the DoP route — Player: DSD track — DSD64 via DoP (176400 Hz carrier) [ALSA Direct] DoP hardware configured: 176400Hz, 2ch, S32_LE Audio thread: DoP playback STARTED which is the outcome that matters: the disc's DSD bits reach the DAC without being converted to PCM at all. Six new strings in all eight locales. Unsupported cases are named rather than approximated — a multichannel-only area, a DST-compressed one and a file that is not an image each get their own message. cc |
||
|
|
d9893b659a |
feat(qt): open the audio CD in the drive and play it
`Open > Open audio CD` reads the disc, builds an ephemeral session and lands
on its tab. Nothing touches library.db, which is the whole point of the
ephemeral path: a disc is content from outside the index that leaves with the
plastic.
It does not invent metadata. CD-DA carries no titles, and the disc measured
here reports its MCN as all zeros, so tracks are "Track N" and the album is
"Audio CD". A DiscID lookup is the obvious next step and is deliberately not
faked: a wrong title is worse than an honest number.
The feeder is the interesting part, and its liveness check took three designs
because the first two were wrong in opposite directions — both caught by
driving the app, neither by any test:
1. `current_track_id() != play_id`: `play_streaming` posts to the audio
thread and returns, so the id is not ours yet and the feeder retires
before reading a sector. Nothing played at all.
2. The same test with a wall-clock grace: a DEADLOCK. That field is stored
only once the audio thread starts playback, which needs the initial
buffer, which is what the feeder is filling. Waiting on it is waiting on
ourselves; the grace expired and the track stopped mid-play.
3. A feeder slot the audio thread plays no part in. A newer disc play claims
it and the older thread retires on its next block. The acknowledgement
path stays for "something else superseded us", but only AFTER the player
confirms our id, and with no timeout — a timeout is what kills a healthy
feeder.
A 16-bit WAV header, not `qbz_dsd`'s: that one is hardcoded to 24-bit, and
reusing it would either lie in the header or promote 16-bit samples to 24.
The shift is arithmetically lossless but it makes the DAC open at 24 bits for
a 16-bit source, which is precisely the silent conversion a bit-perfect path
must not contain. `play_streaming` also does not publish bit depth, so the CD
states 44 100/16 explicitly instead of inheriting the previous track's.
Driven end to end on the owner's disc: seven tracks with the right durations,
CD / 16-bit / 44.1 kHz badges, ALSA Direct opened at 44100 Hz in exclusive
mode, playback past 2:25 with no read errors, and two rapid skips retiring
their feeders in ~160 ms each.
Seven new strings in all eight locales.
cc
|
||
|
|
0e9721d24b |
refactor(qt): reorder the Local Library header and label the Open menu
Owner's layout: the two maintenance glyphs sit together and the action that
produces new content reads last —
Local Library (settings) (resync) [folder] Open v
Open also stops being a bare glyph. A folder icon on its own reads as "browse
folders" when there is a tab literally called Folders two rows below, and this
control is the one door for every medium the pane can hold: a folder now, a CD
and a SACD image next. It is a 30px / radius-6 chip with icon, label and
chevron — the same small toolbar-control contract this header's selects use.
cc
|
||
|
|
9666ef129e |
feat(qt): give the ephemeral pane an album header, a shuffle and zebra rows
The pane opened on a bare title, a track count and the folder PATH — a filesystem readout for something the user is about to listen to. For the common case this pane IS an album page: one folder, one album, one cover. It now reads like one. The cover is 224px, the size LocalAlbumHeader uses, so an opened folder and an indexed album never present the same record at two different scales; the cell collapses entirely when the session has no art rather than reserving an empty square. The second line trades the path for the total playing time, formatted by the same `total_duration` helper every other Local Library surface uses. Play gains a Shuffle beside it, with the same `(shuffle)` shape `play_album` has, and the Clear stays — it is the only way to close a session, and with it the only way to make the tab go away. The per-block header now draws only for MULTI-album sessions. Adding the big header made it a stutter for single-album ones: the same cover, the same title and the same count, twice on one screen. Track rows get `zebra: true`. `TrackRow` already had the property; this pane was simply the surface that never set it. The shuffle's generator was replaced before it shipped. The first version was a bare xorshift with a comment claiming a folder shuffle "is not a place that needs a good generator" — measured over eight consecutive shuffles of a ten-track album, one track opened FOUR of them. It is splitmix64 now, seeded with a counter as well as the clock so two shuffles inside one clock tick cannot repeat, and there is a test asserting no position is pinned over 2000 runs. Shuffle quality is something the listener sees directly. cc |
||
|
|
45c686fab0 |
fix(qt): take the view to the session you just opened, every time
Reported: opening a folder left the view on whatever tab was already showing. The handler watched `ephemeralActive`, and that flag is ALREADY true when you open a second folder over a first — or when the boot restore opened one before you ever touched the view. So it fired exactly once in the life of the app and then never again, which is precisely the symptom. It now watches an open SEQUENCE that changes on every open. The discriminator was already in the code and did not need inventing: `open()` and `open_path()` pass a runtime to `scan`, `rehydrate()` passes None. Bumping only for the former is what keeps a remembered folder from stealing the tab you actually navigated to at startup. Also restores `onEphemeralActiveChanged`, which I deleted by accident in the label commit: a range replacement swallowed the handler sitting between the two edits. It is the fallback that sends you to Albums when the session closes under you, and without it closing a session left `activeTab` pointing at a tab that no longer exists — a blank content area. It had been smoke-tested BEFORE that edit, so the test was valid when it ran and stale by the time it landed. Verified in the reported scenario: standing on Albums with one session open, opening a different folder switches to the tab, the label changes and the count follows. cc |
||
|
|
85ece130e7 |
feat(qt): name the ephemeral tab after what is open, with no verb
"Open", "Now Playing" and "Playing: <album>" were all tried and dropped. A verb is a claim the tab cannot keep: a folder can sit open while something else plays, and then the tab and the now-playing bar name different tracks on the same screen. The content's own name cannot go stale that way, and the tab appears the instant you open something, so nothing has to explain it. The chain lives in Rust so the tab and the nav flyout read ONE value (`localEphemeralLabel`) instead of deriving it twice — the flyout does not parse the ephemeral document and should not start: single album -> its title (a folder IS an album, and so is a disc) otherwise -> the medium's display name, what the pane's header shows neither -> "Media" Elided at 24 CHARACTERS, not bytes: `QbzTabBar` sizes a segment to its text, so an unbounded folder name pushes the whole bar off screen — and slicing a UTF-8 string by byte index panics mid-codepoint on a Japanese album title. There is a test for exactly that. New msgid in all eight locales. Verified on screen: the tab reads "Live 8". cc |
||
|
|
cb4c03ca46 |
feat(qt): one Open menu and one Refresh menu in the Local Library header
The ephemeral pane used to be a third arm of the Folders tab, reachable only
through a folder-open icon buried in the tree rail — one arm of one mode of
one tab. It also HID the folder browser while it was open. Neither survives
the media this pane is about to hold (a CD, a SACD image).
Row 1 of the header now carries two menus, both `CardMenu` (the shared menu
surface, no new component):
- `Open`, always present, is the one door for every medium. Only "Open
folder…" is wired here; the disc entries land with their readers.
- `Refresh` replaces the Plex-only re-sync icon (#573). Rows are APPENDED
per configured source rather than rendered disabled, so a user with no
media servers sees local folders and nothing else. It needed no new Rust:
`plexAvailable`, `mediaHasJellyfin`, `mediaHasSubsonic`, `syncPlex()`,
`mediaSync()` and the settings' own "library-scan" all already existed.
The ephemeral session becomes a tab of its own, present only while a session
is. Two failure modes are handled because the tab can VANISH under the user:
closing the session falls back to Albums instead of leaving `activeTab` on a
tab with no body, and opening one navigates to it — while the pane was an arm
of Folders that was implicit, and with a tab of its own, opening a folder from
Albums would otherwise do nothing visible.
`load_tab_impl` gains an "ephemeral" arm: nothing to load (the session is in
memory), but without the arm every switch to the tab logged "unknown tab",
which is what a real routing bug looks like.
Driven under Xvfb, not inferred: both menus open, the gated rows match the
configured servers, opening lands on the tab, the badges read DSD64, closing
removes the tab and falls back to Albums, and the folder tree is now visible
WITH a session open.
cc
|
||
|
|
71f8d08970 |
refactor(qt): hoist the quality badge into one classifier and teach it DSD
`myqbz_builder_qt.rs` carried its own copy of the reference's `quality::badge` plus three helpers, and its own note called hoisting them out a named follow-up that "must not be done in a domain commit". This is that commit: they move VERBATIM into `quality_qt.rs`. The hoist is not tidying. Local Library never had those helpers, so it read a DSD row's nominal 1-bit depth and 2 822 400 Hz bit rate literally: the CD badge, and "1-bit / 2822.4 kHz" where MyQBZ printed DSD64 for the same file. Six surfaces disagreed with a seventh about the same track. `local_rows` gains a `detail_of` to pair with `tier_of`, and all SIX call sites are routed through the pair — the pairing is what stops the two halves of one badge from drifting again. `tier_of` keeps its extra "max" tier on purpose: `LocalLibraryView.qml:435` filters hi-res on that word and `QualityBadge.qml:66` folds it back to "hires". Measured before/after on the same album: CD + "1-bit / 2822.4 kHz" -> hi-res + "DSD64", on the grid, the rows, the album header and the playlists. cc |
||
|
|
6328994c00 |
fix(library): read "DSD" back out of the database as DSD, not Unknown
`LibraryDatabase::parse_format` is the inverse of `AudioFormat`'s Display,
which is what the scanner stores. It had no arm for "DSD", so every DSD row
came back out of the DB as `Unknown`: the album header printed "UNKNOWN" as
its format, the quality badge said CD (depth 1 IS a known depth below 24) and
the detail line read "1-bit / 2822.4 kHz". The now-playing bar was right all
along because it reads the decoder, not the row — which is exactly the clue
that separated the two data sources.
A fold to `Unknown` is a VALID answer, so nothing failed: not the build, not
`cargo check` (it cannot see the match at all), not a single test.
Three sites had the same hole and all three are fixed:
- `database.rs`, the one that bit;
- `local_plex.rs`, same fold for a Plex server serving DSD;
- `media_servers_qt.rs`, worse than the others — its fallback is `Flac`, so
a .dsf from Jellyfin or Navidrome was not losing its format, it was being
mislabelled as lossless PCM.
The guard is a round-trip over EVERY variant rather than a "DSD parses" test:
adding a variant without an arm now fails here instead of shipping the same
silent loss again. Verified against the real rows: format is stored as "DSD",
so this is a READ-side fix and no rescan is needed.
cc
|
||
|
|
a7b7c1b236 |
fix(qt): light the playing row for a downloaded track, in every track list
The now-playing affordance — the accent pill on the row's left edge and the eq bars in the number cell — never appeared for an offline row on the very view it was played from. `TrackRow` asked one question, QbzPlayer.npTrackId === item.id and for a `qobuz_download` row the two sides come from different id spaces: the queue id is the QOBUZ CATALOG id (the offline tier is keyed on it) while every Local Library list draws the row with its `library.db` id. They never matched, so the row sat dark while its own audio played. The reference has a second published id for exactly this and the port never carried it over: `NowPlayingState.local-track-id` (playback.rs:1978-1986). This adds its twin, `QbzPlayer.npLocalTrackId`, and makes the row match either. Published for Offline rows ONLY, and that restriction is the safety of it: `source_item_id_hint` is a shared slot carrying a Plex ALBUM key and a media server's own item id for those sources, so publishing it unconditionally would hand a list a number from another id space and light the wrong row. It stays empty elsewhere, which makes the second test inert there. Scope checked by driving the window rather than reasoned about: the pill and the play glyph land on the right row for local user files, Plex, Jellyfin and Subsonic both before and after this change — offline was the only source whose id diverged. The other three surfaces that ask the same question need no alias: the queue panel and the kiosk album carry queue ids, and the purchases list carries catalog ids. qbz-qt 431/431, 4 QML audits green. cc |
||
|
|
8c3514b899 |
fix(qt): play a downloaded album from Local Library through the offline tier
A CMAF download's `local_tracks.file_path` is the BUNDLE DIRECTORY (`tracks-cmaf/<qobuz_id>/`, holding `init.mp4` and an encrypted `segments.bin`), not a playable container. `LocalSource::playback` handed it back as a `PlaybackTicket::File`, the read failed with EISDIR, and the audible step reported it as audible: file not available at …/tracks-cmaf/266725026 (drive unmounted?) — a message about a drive, for a bundle sitting on the local disk. The bar adopted the track anyway, so the album looked like it was playing and no audio came out. Three funnels reach the audible step and only two excluded an offline row: `play_current_if_local` (auto-advance) and `playback_qt::play_resolved_offline_aware`. The Local Library PLAY funnel did not, nor did its hand-copy in `local_album_actions`. Only qbz-core's offline tier can decrypt the bundle, and the row carries the real Qobuz catalog id precisely so that tier can be asked for it. - `local_playback::play_audible` is now the ONLY copy of the audible step and routes an offline row to the offline tier; `local_album_actions` drops its copy and calls it. - `LocalSource::playback` refuses an offline row with `BadIdShape` instead of emitting a ticket that lies. The test is on the row data, never `is_dir()`: a stat there would block for the mount timeout on the unreachable share the audible step's bounded probe exists to survive. - Tests run the row through the real `local_queue_track`, because the two halves that must agree are its source word and the badge the router reads. Verified by driving the window: album play, track-row play and skip-next all log `[Core] track ... served from OFFLINE cache` and decode at 44.1 kHz / 24-bit; a plain user-file album still plays through the local audible step at 96 kHz / 24-bit. qbz-qt 431/431, qbz-source 91/91. cc |
||
|
|
900517db57 |
fix(qt): size the filter flyout to its content, not to 372px
The Slint original sized a fixed card around THREE source chips. This port now has five — Plex plus the two media servers — and the row ran to the card's edge, with the last chip all but touching the border. The 372px becomes a floor and the width is derived from the widest chip row plus the 16px padding on each side, capped so the card can never run past the left edge. That also survives a UI-scale bump or a language whose labels are longer, neither of which a fixed number can. `implicitWidth` is safe to measure here: a Row sums its children and a non-wrapping Text reports its natural width, so nothing in the chain reads the card width back and there is no binding loop. An invisible chip contributes nothing, so the card is narrower when a server is not configured. cc |
||
|
|
021b5609e1 |
feat(qt): the Albums funnel survives navigation and restarts
The filter worked but forgot itself twice over: leaving Local Library
reset it, and so did closing the app. The first one is not a bug in the
view, it is what this port does — navigating away DESTROYS the view
(project_qt_nav_eager_tabs), so a `property var filter: ({})` cannot
outlive a trip to Discover.
Both follow from one move: the funnel lives on the BRIDGE, mirrored into
the shared ui_prefs.json. The bridge outlives the view, and the file
outlives the process, so one property buys both kinds of persistence.
`setFilter()` is the only writer, so a mutator cannot change the funnel
and forget to persist it, and the view re-seeds from a Connections on
the qproperty rather than only at construction — a republish has to
reach a view that is already open.
The saved funnel is pruned where the source GATES are published, not
somewhere else, so the two can never disagree: a restored `jellyfin`
tick with no Jellyfin configured would match zero rows while the chip
that clears it is hidden by the same gate — an empty grid with a badge
counting a filter the user cannot see. Only the media-server keys are
pruned; local/offline/plex are always meaningful words.
Verified: ticked Jellyfin, went to Discover and back (badge and grid
held), restarted the process (held), pressed Clear (ui_prefs went to
{}). The prune is reasoned, not exercised — both servers are configured
on this box, so no gate is off to test it against.
cc
|
||
|
|
1a2ebcf146 |
fix(qt): re-seed a line edit on the CHANGE, never on the focus loss
`QbzLineEdit` restored the owner's value with
Binding { target: input; property: "text"; value: root.text
when: !input.activeFocus }
and a Binding re-asserts whenever its condition flips, so BLURRING the
field wrote `root.text` back over whatever had been typed.
A SEARCH field never noticed: it fires `edited()` per keystroke, the
owner republishes, and `root.text` is already what the user typed. A
FORM field is the opposite — it reports on `committed()` and the owner
holds the value until Save — so `root.text` is still the stored value,
which for a password is the literal `""` that is deliberately never
prefilled. Every blur erased the field.
Measured in the media-server panel: username and password wiped each
other, so a Subsonic server could not be signed into by hand at all.
The stored state was in fact correct the whole time — `onCommitted`
fires on focus loss and captures the value before the Binding clears
the view — which is why this survived the Jellyfin round: the form
worked, it just did not look like it.
A republish IS a change to `root.text`; a focus loss is not. Reacting
to the change alone keeps the documented case (an external Reset
re-seeds an unfocused field) and drops the destructive one.
Verified in the window: password survives blurring into the username
box; the Tracks search keeps its query after a blur; the × still
collapses the expandable search with no stale query snapping back.
cc
|
||
|
|
4ba4d336d0 |
fix(qt): route every server album key to the local view, not /album/get
`open_album` asks `is_local_feed_id` whether an album id belongs to the local detail view or to the Qobuz catalog, and that gate's whole vocabulary was the literal prefix `"plex:"`. A `jellyfin:<itemId>` key answered NO, went to Qobuz's `/album/get`, 404'd, and the view NAVIGATED AND LANDED EMPTY — not only from the now-playing bar: every caller of that router shares the gate (the queue, "go to album" in a row menu, the Library ALL feed). None of the other clauses caught it either: a Jellyfin item id is 32-char hex, so it carries neither `/` nor `|`. Spelled through `SourceId::from_word` instead of a second prefix list, so the Subsonic brand spellings come along and the next source does not have to remember this site. `qobuz:` stays excluded — it is a legal word and a catalog id. Verified on the bench: playing a Jellyfin track and clicking the album in the bar opens KARAOKE FIRE!! with its 17 tracks and the playing row highlighted. cc |
||
|
|
8b4e0e92b8 |
fix(qt): treat every server source as non-catalog, not just Plex
Three places still spelled "not a Qobuz row" as "local or plex", which made a Jellyfin or Subsonic row fall into the catalog class: - Library ALL feed stamped the album source from the ID PREFIX (`plex:`), so every media-server album came out as "local" and drew the local hard-drive badge. Reads the row's own `source` word now — byte-identical for local and Plex, correct for the rest. The same failure `local_rows::badge_source` had. - TrackRow's `localSourceRow` gated "Add to playlist" and the offline download entry. A Jellyfin row counted as a catalog row, so the menu offered to send a namespaced synthetic id to the Qobuz playlist API, which adds an UNRELATED track. An allowlist, not `!== "qobuz"`: "offline" is an offline-CACHED Qobuz row whose id IS a catalog id. - AlbumCard hid "Block this album" on local/plex only, so a server album offered to blacklist an id the Qobuz catalog never had. `feed_track_to_queue` is taught the words too. Media-server TRACKS do not ride that feed today (only their albums do), so that one is the guard learning the vocabulary before it is needed rather than a live fix — its existing test, which pins that a local id must never resolve as the Qobuz track carrying the same number, now covers all of them. cc |
||
|
|
13e938727c |
feat(qt): merge media-server rows into both local search paths
`media_servers_qt::search_tracks` was written with the rest of the stack and never called — the compiler's dead-code warning was the only thing that said so. Both places that merge Plex now merge the media servers too: the Local Library Tracks tab and the cortinilla's local section. Two asymmetries are deliberate: - Page 0 only, in the Tracks tab, for the same reason the Plex arm is page 0 only: these caches are bounded sets read whole, so re-merging them per page would repeat every remote row once per local page. - BOUNDED in the cortinilla, unlike the Plex arm. The Plex cache is read whole because it always was; a media-server mirror can hold 50k rows and that path runs on every keystroke, so it takes the same limit the caller asked the local query for. Verified on the bench: "totsugeki" in the Tracks tab returns Jellyfin rows first, then the Plex ones. cc |
||
|
|
322b9374fb |
feat(qt): Jellyfin and Subsonic filter chips in Local Library
The Source row of the filter popup offered Local / Offline cache / Plex, so a media-server library could be browsed but not narrowed — no Plex equivalent existed for it. Adds a chip per media server, each gated on a new bridge property so an unconfigured server never shows a chip that can only ever return zero rows (Subsonic stays hidden here, Jellyfin appears). The fold in applyFilter is the part that is easy to get wrong: rows are stamped with the BRAND the server reports — navidrome, gonic, airsonic, astiga — while the chip is the PROTOCOL. Without folding those words to "subsonic", ticking any source filter would silently drop every row of a Subsonic library instead of filtering it. Verified against the live Jellyfin bench: with the chip on, the grid holds only Jellyfin-badged albums. cc |
||
|
|
df409f6c3c |
fix(qt): resolve media-server art tokens before qbz-models sees them
`qbz-models` is the base of the dependency graph and cannot reach `qbz-source`, so `artwork_ref_with_plex` knows exactly one remote word: "plex". A `jellyfin:<albumId>/<tag>` token fell through to its LocalFile arm, which handed MPRIS a file:// URI to a path that does not exist — the bar and the queue were fine (they resolve through the Qt art cache), only MPRIS was blank. Resolve source-tagged tokens in the Qt layer, where `qbz-source` IS reachable, and hand qbz-models an already-resolved local path. Verified over the session bus: a Jellyfin track now reports mpris:artUrl -> a real 256x256 JPEG in the image cache. cc |
||
|
|
d5ce8bcaeb |
fix(qt): offline-cache rows, Jellyfin playback, and media art on the bar
Three defects the owner found by using it, all mine, all from the same
root: a source word that stops at Plex.
1. AN OFFLINE-CACHE ROW PLAYED NOTHING.
audible: file not available at …/audio/tracks-cmaf/426056576
`SourceId::from_word` folds `qobuz_download` into LOCAL because the row
IS a file — but for a CMAF-cached download that "file" is the offline
cache's own container and only `qbz-core`'s offline tier can read it.
IC-12's first pass declared those rows ours and handed them to the
local audible step. They now stay on the Qobuz funnel, which is where
they were before stage 3 and where their `QueueTrack.id` — the real
catalog id — is meant to be resolved. The symptom was the bar, the
queue and MPRIS all changing while the previous track kept playing.
2. A JELLYFIN ROW CHANGED THE BAR AND PLAYED NOTHING.
`local_queue_track` folded everything that was not Plex or a download
into "local", so the row entered the queue wearing a source word that
disagreed with its namespaced id and carrying no server id at all.
Both media words are now arms of their own, and the hint carries the
SERVER's item id — which `claim` prefers, because it survives a cache
rebuild that renumbers rowids.
3. MEDIA ART NEVER REACHED THE BAR, THE QUEUE OR MPRIS.
Those surfaces resolve an `artwork_url` string with no row in hand, so
`classify` has only the SHAPE to go on — and a Jellyfin image tag or a
Subsonic coverArt id has no shape worth reading. `local_queue_track`
now stamps the token SOURCE-TAGGED (`jellyfin:<albumId>/<tag>`) and
`classify` grows one arm that DELEGATES it to
`registry().artwork_token` instead of guessing. One arm, not 111 call
sites, and it shares its cache entry with the grid because it is the
same call.
Verified in the running window: a Jellyfin album streams and plays —
`[JELLYFIN/STREAMING] 331 MB @ 12 MB/s`, decoder up, 44.1 kHz to the DAC.
429/429.
cc
|
||
|
|
a3aff5f1b3 |
fix(qt): resolve artwork per VISIBLE row again, not per row of the document
The albums grid got slower and the owner was right about it. This is the cause and it is mine. Stage 4 moved the artwork decision from window time to MAP time so that a row's provenance would still exist when it was made. That fixed bug 3 and introduced a worse one: a remote source answers "what is this token" by reading its CREDENTIALS, and the map runs over every row of the document, not over the ~50 on screen. `local_plex::settings()` is a `load_last_user_id` file read, a mutex and a SQLite query — per Plex row. Measured on the owner's library, release, same grid each time: before stage 4 (a string clone) ~2 µs/row after stage 4 (resolve per row) 39-92 µs/row <- the regression + memoised settings ~9 µs/row + resolve at window time again ~1.8 µs/row 1703 rows: map 67-71 ms -> 3.0-3.7 ms The art index now holds `(SourceId, raw token)` instead of a resolved `ArtRef`. Provenance still survives — nobody sniffs a string, which was the whole point — but the resolution happens where it always did, in `resolve_window_blocking`, over the keys actually on screen. Same information, right place. The settings memo stays, in both `local_plex` and `media_servers_qt`: those accessors are cheap gates that several callers ask repeatedly, and every writer invalidates so a connect, a disconnect or a user switch is still seen on the next read. `search_local` keeps resolving inline on purpose — it is the cortinilla's bounded 76-row result set, and the url/path split it needs is per row by definition. 428/428. Grid verified in the running window: Plex, Jellyfin and local badges all correct, covers resolving. cc |
||
|
|
f2d8111721 |
perf(qt): instrument the albums load — sql, map, serialize
The albums document is republished on every mount of Local Library (five
times in a 37-second screencast) and there was NO instrument anywhere on
that path, so "the grid got slower" could only be argued about rather
than measured.
Three segments, because three different things could cost: the SQL (which
now ATTACHes two mirrors and UNIONs three sources), the row mapping
(which resolves an ArtRef per row since stage 4), and the JSON handed to
QML.
Measured on the owner's library, release build:
1703 rows (local + Plex + Jellyfin) sql 59-88ms map 67-71ms ser 2ms
1271 rows (local + Plex) sql 42-88ms map 50-117ms
29 rows (local only) sql 3-7ms map 0.06ms
The two large configurations overlap within noise: 432 more rows cost
nothing measurable in Rust, and serialisation of 593 KB is 2 ms. Whatever
the grid pays for row count is paid AFTER this line — the QML parse and
the delegate rebuild — which is exactly what Rust could not see before
and what these numbers now bound.
cc
|
||
|
|
3cfce8a769 |
feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every difference between the protocols is a property set at the call site, so they are visible in one place instead of drifting across two files: "Test" proves the address for Jellyfin and only "a Subsonic server answered" for Subsonic (which has no credential-free endpoint), the password is discarded for Jellyfin and kept for Subsonic (no session, md5(password+salt) per request), and the sync cost is stated up front — 45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not read as a hang. The credential never leaves Rust; the panel gets `hasCredential`. Assets: the colour marks for cards, the monochrome ones for rows. Both mono files arrived as BLACK ink and are invisible on a dark theme untinted, so they were normalised to the #ffffff master ink and joined `icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an SVG per theme. The Jellyfin colour asset had a white rounded-rect plate behind the mark; it was stripped, because every other brand mark here is transparent and a white tile beside them reads as a rendering bug. Rendered with Qt itself and looked at before any of it was wired. CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo, and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping` reports the server's own type and it is already persisted, so branching later is cheap — a known wrongness, not an unnoticed one. THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by driving it under Xvfb rather than by reading the log: 1. `MediaServerSettings is not a type` — a .qml missing from build.rs is not in the QML module. It resolves fine on disk, compiles nothing, and blanks the WHOLE settings page at runtime. All three existing audits passed. `qml_module_registration_audit.py` is the missing check and is now in the gate. 2. The toggle would not move. The panel reads its state from the settings DOCUMENT, unlike Plex's toggle which reads a bridge property, so every mutation has to republish it — connect, enable, disconnect and the end of a sweep now do. 3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge: `local_rows::badge_source` had never been taught the two words and folded them into `_ => local`. Not cosmetic — that string is what the source chips filter on. `play_single_from_source` also grew the arm it needed: a media-server row from the recently-played rail has no library.db row to mint a LocalTrack from, so it resolves through `registry.tracks_of` instead. END TO END, from the UI, against the real server: toggle on -> type the address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned, 4924 cached" -> Local Library shows 461 albums with their covers and the Jellyfin badge. The cached quality distribution is identical to what the server reported in the original protocol sweep. 428/428. cc |
||
|
|
63a4f2daf1 |
feat(qt): the Local Library union, the settings store and the sweep
Three pieces that only mean anything together: rows in the shared cache,
a union that shows them, and settings that decide whether either
happens.
THE UNION. `get_albums_metadata_page` grows a second, INDEPENDENT
attachment. `plex_cache` stays; `remote_cache` is the shared mirror, and
they attach separately on purpose — a missing Plex cache must not cost
the user their Jellyfin rows. `attach_best_effort` is extracted because
the failure mode of a botched ATTACH is a silently local-only library,
and a failure stays non-fatal: refusing to list any albums because one
server is off turns "Jellyfin is unplugged" into "your library is
empty".
The union takes an ENABLED-SOURCES list, not just a path. The mirror
holds every remote source, so the master toggle has to be a filter
rather than a detach: turning Jellyfin off must hide Jellyfin's rows and
leave Subsonic's alone. That predicate is interpolated into SQL, so it
is VALIDATED rather than escaped — only [a-z] survives, an all-invalid
list yields `0` (show nothing, the safe direction), and a test feeds it
`x'; DROP TABLE local_tracks; --`.
The remote group key is `<source>:<album id>`, matching Plex's
`plex:<hash>`. That namespaces two servers using the same album id AND
lets a source claim an album card that arrives with no source word,
which is every card that round-trips through a QML string property —
`recognises` grew that arm and `claim` strips the prefix, because
carrying it inward is how Plex ended up with two album keys only one of
its queries understood (IC-2).
THE SETTINGS. One store for both servers, keyed by a `server` column,
mirroring the shared-cache decision. What each protocol needs differs
and `is_configured` is the one place that knows: Jellyfin needs its
issued token, Subsonic needs a password because it has no session and
re-derives `md5(password + salt)` per request.
Two identifiers are minted once and persisted, and both are
load-bearing. Jellyfin's DeviceId, because re-authenticating under the
same one revokes the previous token — a constant would make two installs
log each other out, a fresh one per launch drops the last run's token.
Subsonic's salt, because rolling it per request makes every cover URL
unique and the artwork cache keys on the URL.
THE SWEEP. In the frontend, where the runtime and the progress UI are,
writing through the cache handle the SOURCE owns so sweep and reads can
never point at different users. Jellyfin gets progress and a delta path
(45.8 s for 4924 rows); Subsonic needs neither (0.81 s for 6678).
`prune_stale` runs only after a COMPLETED full sweep — a delta never
prunes, because it deliberately does not see unchanged rows and would
read every one of them as deleted. `last_sync_at` is stamped only on
success for the same reason.
Verified against the running binary, twice:
source registry bound: local_tracks=617 plex=connected
ephemeral_store=yes remote=none configured
jellyfin: minted a device id for this install
subsonic: minted a salt for this install
...and on the second launch, zero re-mints and the same values on disk —
which is the property that keeps a Jellyfin session alive.
qbz-qt 428/428, qbz-app 224/224, qbz-library 100/100, qbz-source 88/88.
Release build clean, zero warnings.
cc
|
||
|
|
604f336e24 |
fix(qt): wire the source registry to its stores — it never was
This is the regression report from the previous build, and the cause is that stage 1 built three injection sockets and nothing ever plugged into them. `set_creds`, `set_ephemeral` and `bind_user` had zero call sites in the entire frontend. It stayed invisible for two stages because nothing needed them: `claim` is pure, and `PlexSource::tracks`/`meta` read plex_cache.db through qbz-plex's own global. The first consumers that DO need them are playback and artwork — stages 3 and 4 — so routing those onto the seam turned a latent gap into a live break: - every Plex row: "plex: not configured (no Plex credentials configured)", so Plex playback died and every Plex cover in the Local Library grid resolved to ArtRef::Unavailable and vanished; - every LOCAL row: an unbound DbPool refuses all reads, so LocalSource found no track and local playback died too; - ephemeral rows had no session store to resolve against. Wired now, each to the ONE authority that already existed: PlexCreds over local_plex's settings store (read per call, never cached, so a connect/disconnect mid-session is picked up), EphemeralTracks over the session store, bind_user as the first statement of bind_per_user_stores, teardown on logout, and the unbound-window fallback so pre-login reads resolve the way local_state::with_db already resolves them. Also wired: the album identity mode. The grouping IS the query, so a metadata-mode user's album play would have resolved a different track list than the grid rendered. `report_wiring` logs one line at bind time asking each socket a question only a wired one can answer. The failure this fixes is silent by construction — an unbound pool and a disabled Plex look identical to a user with no local tracks and Plex switched off — and it stayed hidden because the tests are pure-function tests and the structural gate only proves the OLD path is gone, never that the new one is connected. Verified against the running binary, not the test suite: source registry bound: local_tracks=617 plex=connected ephemeral_store=yes qbz-qt 423/423, qbz-source 59/59. cc |
||
|
|
68235a4012 |
refactor(qt): the Local Library art index carries an ArtRef, not a string
Design 02 §9 stage 4, the Local Library half. `local_state.art_index` becomes `HashMap<String, ArtRef>`: the row mappers register what the row's OWN source says its artwork token means, at the last moment that provenance still exists, and the windowed pipeline reads that answer instead of re-deriving it. `artwork_qt::classify` is gone from every caller outside artwork_qt itself. It recognised a Plex thumb only because `local_plex:: is_thumb_path` was bolted onto it, so a token from any future source that starts with `/` — a Jellyfin `/Items/<id>/Images/Primary` — matched its filesystem-path arm and the pipeline opened it from disk: blank cover, no error, nothing in the log. That is bug 3. Producers wired: both row mappers, the folder tree (two sites), the ephemeral pane, the album-detail header and the Artists rail. The Artists rail needed real work rather than a cast. Its portrait comes from one of three chain tiers (custom image, Plex portrait, borrowed album cover) while `MergedArtist.source` describes the artist's LIBRARY provenance and is legitimately "mixed" — which is not a source a token can be interpreted by. `AlbumCredit` now carries its album's source word so a borrowed cover keeps that cover's provenance, and `MergedArtist` reports `image_source` beside `source`. `fetch_plex_misses` carries `(artKey, fetch url, cache key)` instead of collapsing the last two. They are not the same string — a Plex url is re-tokenized every pass and a Qobuz one carries a size query, while the key stays put — and collapsing them re-downloads covers already on disk. `artwork_qt` grows `cached_path_for` / `download_pairs`, the classification-free entries, sharing one download loop with `download_missing` rather than a second copy of the pool and the error-page guard. Still on the old path and NOT in this commit: the Home / queue / now-playing surfaces, where the string arrives as `HomeCard.art_url` or `QueueTrack.artwork_url`. Those need IC-7 (HomeCard has no source field) and the eight hardcoded `"source": "qobuz"` QML payloads first. 423/423. cc |
||
|
|
22033672a1 |
fix(qt): claim the row before the Qobuz funnel, and before casting it
Two remaining stage-3 consumers. `play_resolved_offline_aware` is the QOBUZ entry — the tier walk, the offline cache, the quality request — and every MyQBZ and album play arm sent its anchor there regardless of who owned the row. A local, ephemeral or Plex first track resolved and then failed AT THE PLAYER with "No Qobuz client available", which `myqbz_play_qt`'s own header documented as open. It was also unsafe: the offline cache is keyed on the raw u64, and a local_tracks rowid or a Plex rating key shares that small-integer space with older Qobuz ids, so a collision played the wrong track rather than failing cleanly (#638 fix 3's sharp edge). The funnel now claims the row first and hands a non-Qobuz one to audible_qt, which fixes all six call sites without touching a signature. The claim is gated on the core's cursor already sitting on that track — true for every caller, since they all set the queue and then play its anchor — so a caller that plays some other id behaves exactly as before instead of guessing. `cast_qt::resolve_local_path` becomes a PlaybackTicket consumer (IC-10). It was an ephemeral test plus a bare db.get_track on a QueueTrack.id, which is only sometimes a library.db rowid: a Plex row carries a namespaced id and an offline row carries the Qobuz catalog id, so both missed and reported "Local file not found" — an answer naming the wrong problem. Now File/DsdFile is servable and everything else is a named refusal, which covers every remote source by construction rather than the one word somebody thought to list. The Plex arm stays an early refusal so it does not pay a network round trip to build a url it would throw away. Structural gate: no plex_resolve_track_media / plex_rating_key left in qbz-qt outside comments. Four source-word matches remain on purpose — local_queue_track STAMPS the word (producer side, not consumer), play_single_from_source looks up a ROW rather than playing it, cast's early refusal above, and batch_all_qconnect_castable, which asks "does this row have a real Qobuz catalog id?" — a different question from ownership, and one SourceId cannot answer since qobuz_download maps to LOCAL while being castable. 423/423. cc |
||
|
|
a55e426330 |
refactor(qt): collapse the three audible steps into one PlaybackTicket match
Design 02 §9 stage 3. `local_playback::play_audible`,
`local_album_actions::play_audible` and `local_ephemeral::play_file` were
three near-copies of the same routine, each matching on `track.source`
by hand, and they had already drifted:
- `local_playback` carried the `plex_rating_key` ladder — one
engineer's reconstruction of a five-id table nobody wrote down, whose
fallback is right for the MyQBZ path and wrong for the LocalLibrary
one. `local_album_actions` held a copy of it, kept in sync by comment.
- both handed `track.id` to `play_local_file` for anything not Plex,
which is wrong for OFFLINE rows: their `QueueTrack.id` is the Qobuz
catalog id while the `library.db` row id rides in
`source_item_id_hint`, so the lookup missed.
- the CUE fast path existed in two shapes. `local_playback` compared
track ids, and since every virtual track of a CUE album is its own
row that test only passed when replaying the same virtual track — it
never fired for the case it exists to serve. `local_ephemeral`
compared file paths, which is the right question, but only for rows
in the session store.
`SourceRegistry::playback` now claims the row once and `audible_qt`
performs the ticket. The CUE test becomes exact for every source:
`audible_qt` records what its last `play_data` actually read, so "is
this container already loaded?" is answered against a real path with no
source lookup on the hot path.
`play_current_if_local` stops matching `Some("local") | Some("plex")`
(IC-12). Six source words occur on a queue row and the four it missed —
`user`, `ephemeral`, `qobuz_download`, `qobuz_purchase` — all name
files on disk, so auto-advance onto one fell through to the Qobuz
tier-walk, died with "No Qobuz client available", and
`auto_skip_unavailable` then declined to skip because that error is not
terminal-unavailable: one ephemeral track could stop playback dead.
The whole-body fallback in the streaming arm checks Content-Type and a
minimum length before the bytes reach the player. Plex cannot trip it,
but a Subsonic server answers a refused request with HTTP 200 and a
~200-byte JSON error envelope (measured against Navidrome 0.63.2), and
that must never reach the decoder.
Deleted: `play_local_file`, `play_plex_track`, `plex_rating_key` and its
three tests (the same cases are already pinned on `PlexSource`),
`local_ephemeral::play_file`, and the duplicate `play_audible`.
cc
|
||
|
|
321d8fe753 |
fix(qt): align the playlist manager's card grid with its own headings
The grid delegate carried x: 32 on the delegate ROOT. A ListView owns its delegates' position, so an x set there is not reliably the one that survives layout, and it did not: the cards sat about 24 px left of "Folders (12)" and "Playlists (9)", which PmPageHead draws at 32 through its own Column. Headings at one inset and cards at another, on the same page. The list and tree delegates right below it already use the shape that works — a full-width delegate with the inset on the child — so the grid now does too. The inset lives where the view has no opinion about it. cc |
||
|
|
7e233104f5 |
feat(import): playlist files, JSON, ListenBrainz and Last.fm as import sources
Implements qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md, whose §11 the owner ratified 2026-07-31. PlaylistSource is an enum, not a trait (ADR-007): the set is closed, there is no plugin story, and every arm resolves to the same ImportPlaylist the URL scrapers already produce — so the matcher, the 2000-track split, the sink and the summary never learn a second kind of source exists. import_prepared_playlist takes the playlist BY VALUE and import_public_playlist becomes a shim. That kills the double scrape the Tauri re-fetch-on-execute left behind, and it is not optional for the new sources: the rfd bytes of a picked file are gone by execute time and the dialog cannot silently reopen. It also closes a real race — open() resets the session, so a modal reopened between the Import click and the task's read handed a detached task None. FILES read the track list ONLY. The paths inside are never opened, copied or added to the Local Library, and the modal says so in a persistent row rather than a tooltip. Content sniffing beats the extension, with an HLS guard because .m3u8 is also the stream-manifest extension, and a utf8-then-Windows-1252 fallback because .m3u and .pls predate UTF-8 and "Björk" has to stay matchable. JSON locates the track list BY SHAPE. Depth is a DoS bound, not a locator: the array sits at 0, 1, 2 or 3 levels depending on the export, and a hard two-level rule drops an ordinary GraphQL envelope. Container demotion keeps a discography from importing album names as track titles. Walls at 16 MiB, depth 8, 2M nodes and 10k tracks. SERVICES are public reads with no authentication; a connected account only prefills the field. No qbz-integrations edge — its ListenBrainz client returns tracks with no duration and drops the playlist title, both verified in its source, and a direct GET /1/playlist/<mbid> reads all three in one request. A Last.fm STATION IS A RADIO, and ?page=N is not pagination. Measured against a live profile: pages 1 and 2 overlapped and batch sizes varied (20, 20, 36). The first cut fetched 20 pages, never hit its empty-page exit, and returned 469 rows holding 198 distinct tracks. It now dedupes as it draws, stops after two dry draws, and stops at MAX_STATION_TRACKS = 200 — the number shown in "Found N tracks" has to be one we can deliver, and a station is dynamic enough that a bigger single import buys little (owner ruling: no false expectations of "traeré casi 500" when we bring half). A failed draw no longer fails an import that already has rows; Last.fm rate-limits a burst of these. The summary reports THREE DISJOINT NUMBERS that sum to the total. skipped is now counted from the matches that found nothing, not inferred as total - matched, which folded duplicates into failures: that same import told the user 271 tracks were skipped when 255 of them had matched perfectly well and been deduplicated before the add. 23 new strings across the seven translated locales; en is the sparse fallback catalogue and needs none. cc |
||
|
|
c11bdf0654 |
feat(qt): bring back the New playlist modal, and delete the shortcut
The sidebar "+" called create_playlist(), which made a playlist named literally "New Playlist" and navigated to it. Four of the modal's five fields had no other door, and one had NO door at all: name hardcoded reachable in Edit playlist description always empty reachable in Edit playlist folder always root reachable in the sidebar row menu public always false NOWHERE offline-only from connectivity NOWHERE for an online user That last row is why this counts as a regression rather than a rough edge. offline_only decides whether the playlist is a local_playlists row (local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity, and the shortcut derived it from connectivity alone — so someone with a Qobuz session could not create a local playlist without pulling the network cable. Ported against CreatePlaylistModal.slint plus on_create_playlist (main.rs:21107) and on_submit (:21496). The submit branches where the reference branches, and a creation forced local by being offline KEEPS the flag, as the reference's own note says, so Edit can unmark it later to enable "Upload to Qobuz". Two calls made here: - Create rides the QbzPlaylistEdit singleton rather than getting its own, where the reference splits them. Same domain — a playlist's own metadata — with two documents that never interact, and a second QObject is a second boot() to forget; that bridge's header already documents a missing boot() swallowing publishes silently and forever. - The folder picker hides on the offline-only arm. folders_qt::move_playlist takes a u64 and a local:<uuid> has none, so the reference shows the control and discards the value. The shortcut is deleted rather than left beside the modal: a second creation path that skips it is the fork the port rule forbids. All thirteen strings were already in the catalogues. cc |
||
|
|
e1876c15f0 |
feat(qt): the artist page's radio disc goes live, with a start-up indicator
The disc was btnEnabled: false with a comment saying neither engine had a seam on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio (/radio/artist) — and that is what this restores. The smart arm already shipped behind Discover's spotlight RADIO card and simply was not wired here; the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album radio uses, because its track objects are just as bare). Both labels already exist in all eight catalogues, so no new msgid. AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write; until the first track resolves nothing on screen moves, so the click read as dead. QbzCircleAction gains the `loading` arm the reference already has (CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins only the station that was clicked instead of all six. The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no partial redraws, so a continuous animator presents the whole window at display rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per ~30 Hz tick, and the Connections is disabled unless the button is spinning and on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a RotationAnimation and is deliberately not reused. On the cards, `loading` also pins the hover disc up: it only exists on hover, so it vanished the moment the pointer left after a click, which is what made the click look swallowed. A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty result all clear it. A disc spinning forever is worse than no indicator. cc |