The Dock-hiding policy read the tray settings through `.get()`, which does not
exist on `TraySettingsState`; the method is `get_settings()`, as the same file
already calls it twelve lines above. The call sits inside a macOS cfg block, so
Linux and Windows compiled cleanly and only the two macOS release legs failed.
Verified by a release build on the Mac mini.
cc
The workspace version and the release documents landed with their own changes;
this adds the metainfo release entry the Flathub manifest and the software
centres read. Snap is stamped from the tag and needs no edit.
cc
The specification types ids as strings and Navidrome, Gonic and Airsonic send
them that way, so the song rows deserialized them as `String`. Funkwhale sends
integers: the album list already tolerated both through `json_id`, the song
rows did not, and a pod connected, listed its albums and then aborted the whole
library sync with "invalid type: integer 2059, expected a string".
Ids, album ids and cover-art tokens now accept either shape and normalize to
text; the value is opaque to this client and only ever travels back in a query
string. A numeric `coverArt` in an album row also stopped resolving, which cost
that album its cover in silence.
Verified by unit test with a Funkwhale-shaped payload; string ids map exactly
as before.
cc
A visual deck that opens once per version, its five cards written by the owner
and translated into all eight locales, a panel wide enough that the card art is
no longer cropped, and the release notes capped to a readable measure inside it.
Carries the authorship-and-responsibility statement for README and CONTRIBUTING,
and credits Maxou56800 in the About chips, the README list and the 2.1.2 notes.
cc
The shared-layer conflict fence (#795) reaches the release notes beside the
QConnect fixes it belongs with, and the README contributor list gains its line.
cc
The owner's copy, verbatim in English, translated into the other seven. The
deck now says what each card is for instead of announcing that a menu moved:
the settings card invites the reader into Settings, the cache card hands the
choice to them, and the Connect card says out loud that the feature is still
being worked on.
The loudness card's title reuses the existing "Volume normalization" msgid
rather than adding a near-identical string, so the card and the Audio settings
label can never drift apart. Adding a second spelling of it is a duplicate
definition, which msgfmt rejects outright.
cc
The deck's art is 16:9 and the image covers its box, so at 820 wide the box sat
near 1.34 and Qt cropped roughly a quarter of every frame's width — taking with
it what the right edge of a screenshot is there to show, such as the renderer
name on the iOS capture. At 1040 the box lands near 1.72 and the crop all but
disappears.
The release notes ride in the same panel and are prose, so their column is
capped at 720 and centred; a line that spans the full 1040 reads measurably
worse. Narrow windows keep the full width.
cc
The human who signs a commit is its author and is responsible for it. Models
are disclosed in the PR body and never added as co-authors, because credit
without accountability is worth nothing: an agent cannot answer a review
comment six months from now.
Spells out what that means for the three things contributors send. Findings
are welcome verified or not, as long as the report says plainly what was
observed and what was not reproduced. Fixes must be verified by the person
sending them, with what they ran and what they saw. A generated code review is
not a review: submitting an unfiltered list moves the work of separating real
from imaginary onto the maintainer.
cc
Clears local_playback_conflict_pending in the success branch of the local-playing takeover, matching the abandon and failure branches beside it.
Defensive reinforcement, not the root cause: both hosts already clear that flag when the takeover publish succeeds (qbz-qt/src/qconnect_qt.rs, qbzd/src/qconnect/publish.rs, since f6e1c4e02), and the fence that actually kept dropping renderer commands after a reconnect was the takeover echo never being confirmed, fixed in bca798374 by confirming it through its own action uuid. Holding the invariant in the shared session loop as well means a host that stops clearing it, or a new host, cannot reintroduce the stuck fence.
Reported and verified against the official Android client by Maxime Berthault.
Tag details and changelog for 2.1.2, from the 162 commits since v2.1.1:
playback memory profiles, rebuilt volume normalization, the Storage section,
the Qobuz Connect hardening round and the quality-of-life pass, with the fixes
grouped by area.
cc
The modal fetched the matching GitHub release and rendered its markdown, and
it only ever opened from the hamburger menu, so the release's headline changes
reached nobody who did not go looking for them. It now opens on the curated
deck for the running version: five cards, each one screenshot collage plus a
title, two lines and a button that closes the modal and lands on the setting it
just described. The rendered release notes become the deck's last card, so
nothing is lost and a version without a deck opens straight into them.
The cards' text lives in Rust and travels to the eight catalogs as ordinary
msgids; the artwork carries no text, so one set of images serves every locale.
The scrim over the art is painted by QML rather than baked in, which keeps the
contrast in both themes. Card changes are instant and there is no auto-advance:
a continuous transition would present the whole window for its duration, and
the repaint-pulse contract reserves that for the shared shell pulse.
Dismissing the deck records the running version, so it opens by itself exactly
once; the hamburger row reopens it at any time.
cc
Restore hidden windows from Dock/Finder and add a macOS-only Dock visibility setting. Share the app background with the miniplayer, improve theme contrast and translucent controls, and allow dragging from artwork and free areas.
Bump the workspace version to 2.1.2. Add lifecycle and miniplayer regression coverage. Linux release, QML software/OpenGL tests and isolated startup passed; native macOS validation remains pending.
Refs #792, #793
cx
Capture the matching sink before ReserveDevice1 can hide it, including successful first opens. Recover it after PCM teardown without substituting the current default output. Bound pactl recovery calls and retain failed targets for retry.
Validation: 148 audio tests pass, including 6 sink recovery regressions. Physical DAC/browser/tray smoke remains pending.
cx
A shutdown that restores only the owner queue must not persist the stopped player position over the saved owner resume point. Track that teardown outcome and retain the existing session snapshot.
cx
Request output release with the first quit watchdog arm, independently of network teardown. Permanently reject late playback/reinit commands, interrupt abandoned feeders, and await release acknowledgement. Restore the owner queue at Connect shutdown without scheduling playback.
cx
Wake blocked streaming readers without canceling their download before device teardown. Recreate streaming and direct DSD playback at the saved position after pause; paused seeks keep the DAC released. Prevent direct DSD resume after leaving ALSA.
cx
Only acknowledge the recorded sink, retaining failed recovery for reset retries. Restore the Slint device refresh settle delay and release before resetting routing settings. Abort an ALSA backend change when release fails.
cx
Accept the append slot without clamping it onto the last row. Reject stale coordinates and preserve adjacent no-op moves and server-authoritative occurrence ordering. Covers beginning, middle and end moves.
cx
Preserve UUID identity in ADD and UPDATE frames so identical device names can resolve the local renderer. Cover wire decoding and UUID-first resolution with missing-UUID fallbacks.
cx
The report that answers a renderer command carried no queue ids at all. An
omitted id is 0 on the wire and 0 is the FIRST queue item, so every
command-driven track change told the session "I am at the head of the queue"
until the next periodic report corrected it, and controllers visibly jumped
there. The ids are now resolved against the synchronized queue, the same
resolution the periodic report uses, and stay omitted when they do not
resolve: an id the server cannot match is still never sent, which is what
40a2fa618 protected and 1d2873045 already restored for transitions.
PARTIAL. It reduces the controller's jump but does not eliminate it: an
induced smoke still shows one. The remainder is not this field — the ids on
the wire are correct — and tracks the audio backend instead: with PipeWire
output the jump disappears, with ALSA Direct it appears, and that path closes
and reopens the device on every track change while QBZ reports every 2s where
the official renderer reports every ~10s. Investigation and evidence in
qbz-nix-docs/qconnect-hardening-2.1.2/Q14.5-PROPUESTA.md §14.
cc
Gate remote volume and mute before state changes or success reports, and advertise the runtime volume policy on owner and delegated joins.
Cover the daemon sink and engine with locked/software regressions. Validated by 506 tests, release build, and volume/handoff smoke.
cx
Q-20 renderer targets with autoplay in the session, Q-14 the persisted device
name on reconnect, Q-14.5 the takeover fence that discarded every renderer
command after reconnecting while playing, and the negative queue_item_id that
made the last row of every queue unreachable from a controller. Plus the album
view's Add to queue, which was playing later, and the report diagnostics that
found all of it.
Each fix carries its regression test and was smoke-tested by hand against the
iOS client on 2026-09-15.
cc
Three separate hunts this session ended at the same blind spot: the periodic
report never printed the ids it announces, and a peer renderer's echo printed
none of the cursor it reports. Both are what a controller builds its row
selection from, so a wrong or missing id there looks like "the controller
ignores us" with nothing in the log to say why.
Allowlisted scalars only, at debug, following the rule already written for
loop_mode: the payloads themselves stay unlogged because they can carry session
and delegated-credential material.
cc
The album track row folded every enqueue mode that was not "next" into
"later", so "Add to queue" inserted on the manual block's tail — right after
the current track, under the "Next in queue" header — instead of appending at
the end. Three adds during a smoke test left the queue with the same recording
three rows apart, which then fed a duplicate-occurrence hunt in QConnect.
TrackRow emits "next" | "later" | "queue" and enqueue_album_track has an arm
for each: the fold is a leftover from when that path had no block-tail arm. The
mode now travels verbatim. No other surface collapses modes.
cc
Commanding the LAST row of a queue was impossible from a controller: the tap
reached QBZ as nothing at all, no error, no log, while every other row worked
and repeat-all hid the whole thing. The server marks "there is no such item"
with queue_item_id = -1, and it sends that for next_track precisely when the
row it commands is the last one. The decoder treated any negative id as an
invalid payload and rejected the entire batch, so the SET_STATE carrying the
user's tap was dropped before it ever became a command.
A renderer occurrence with a negative id now decodes as absent, which is what
it means, and only on the renderer SET_STATE path where the wire capture is.
Queue-list decoding keeps its strict conversion.
Both hosts share this decoder, so the daemon had the same unreachable last row
and is fixed with it. Wire-captured and verified by smoke test on 2026-09-15:
the tap that logged "Renderer commands decode error: negative value where
unsigned expected in 'queue_track_with_context.queue_item_id': -1" now logs
"Renderer commands decoded: 1" and the renderer moves.
cc
Reconnecting while local audio plays left every renderer command discarded:
local authority claimed the renderer, published its queue and then waited for
an echo it could never accept, so the takeover fence stayed armed. The
controller kept seeing a healthy device because outbound reports never stop,
while track changes, pause and seek were all dropped until an unrelated local
action republished the queue.
The takeover's QueueLoadTracks travels in the same burst as AskForQueueState
and SetActiveRenderer, and the pending-action slot holds one action, so that
publication is never correlated and its echo stays unconfirmed. It is now
confirmed by the action uuid the server echoes back, independent of that slot.
The echo is also evaluated before the playback-conflict gate, since accepting
it materializes nothing and only retires the fence.
Adds the rejected-echo diagnostic and the inbound action uuid to the debug log:
the previous silence there cost three rounds of bisecting. Regression tests
cover both halves and fail without the fix.
Verified by smoke test on both paths, manual reconnect and a real WebSocket
drop: the queue is accepted in ~180 ms and the iOS client controls immediately.
cc
The initial connect threaded the custom device name into the join payload, but
the in-loop reconnect passed None on both hosts, so the rejoin announced the
default "Qbz - {hostname}" while the local identity kept resolving the saved
name. One device_uuid with two friendly names breaks the renderer self-match
when ADD_RENDERER omits the uuid, and the renderer then goes mute.
The reconnect now reads the same persisted name the local identity, the
deferred renderer join and the LAN projection already use. Nothing else in the
reconnect lane changes: no order, timing, backoff, join reason or rejoin
difference. A debug line records the announced name.
Verified by smoke test: a real WebSocket drop rejoined announcing the custom
name, and the iOS client kept both the name and control.
cc
align_remote_occurrence required the local queue to equal queue_items plus
autoplay_items, but materialization only installs queue_items. With autoplay
suggestions in the session every SET_STATE with a track failed with "local
queue does not match synchronized occurrences".
The strict occurrence fence now compares the local queue against queue_items
only. Queue targets keep the occurrence lookup, shuffle projection and cursor
move; an autoplay target is loaded by id without touching the local queue,
cursor or shuffle projection. Verified by smoke test against the official
iOS client with autoplay, including shuffle.
cc
test-crates failed on a timing-dependent deadlock in the library service's
shutdown test. Shutdown now releases the worker slot before joining, so a
concurrent scan answers at once; a deterministic regression test covers it.
cc
LibraryService::shutdown took the worker handle in an `if let` scrutinee,
which keeps the slot's MutexGuard alive through the block and therefore
through the join. A scan requested meanwhile waited for the whole running
scan instead of returning "closed", and the shutdown test deadlocked on its
own release channel whenever the joining thread won the race: test-crates on
pre-release failed with a 5 s Timeout, reproduced locally once in 1340
single-CPU runs.
Take the handle in its own statement so the guard drops before the join. A
new deterministic test issues a scan while shutdown joins a blocked worker
and requires an immediate "closed" answer; it timed out before the change.
qbz-library passes 213 tests, and 1296 single-CPU stress runs of the
original test pass.
cc
The test-crates Qt gate on 6.8.3 failed to load QbzArrayModel, which would
have left AlbumView and the kiosk album and playlist lists broken in the Qt
6.8/6.9 release builds. The model is now assigned from its rows and the
delegate is handed to the model; the gate QML suites pass on 6.8.3 and 6.11.
cc
The test-crates gate on Qt 6.8.3 failed to compile QbzArrayModel:
DelegateModel.model has no change signal there, so onModelChanged was a
non-existent property and the component, with AlbumView and the kiosk album
and playlist lists, did not load. x86_64 Linux release builds use Qt 6.8.3;
aarch64, macOS and Windows use 6.9.3.
Assign the model from onRowsChanged instead, capturing the viewport before
the swap. A probe also showed Qt 6.8.3 renders zero rows when the delegate
is set on the view rather than on an external DelegateModel (6.11 forwards
it), so the three hosts and the gate test now hand their delegate to the
model.
tst_array_model passes 6/6 on Qt 6.8.3 and 6.11.2; with Qt 6.8.3 qmllint,
the six QML audits, all fifteen gate QML suites and the Node gate tests
pass.
cc
Track rows play on double click on every desktop surface, keep the play
button's single click, and preserve drag, recycling and Shift/Ctrl
selection; kiosk rows keep tap-to-play (#790).
Jellyfin authenticates with the MediaBrowser Authorization header required
by 12.x, fixes incremental sync on 10.8-10.10, and is covered by a
cross-version compatibility lab passing 10.8.13 through 12.1 (#502).
Media-server albums edit their metadata sidecar from the album header, API
keys and the stored token are redacted from logs, and a saved Jellyfin
session no longer reads as disconnected after a server test.
Verified by smoke test: a full album played through Jellyfin.
cc
With a stored token, Test answered "Server reachable. Sign in to connect.",
which read as a lost session although the connection was intact. Show the
existing saved-connection status instead when a credential is stored.
cc
The per-track editor already opened Plex, Jellyfin and Subsonic albums on
their QBZ metadata sidecar, but the album header's pencil always opened the
local-library target and stopped at "Metadata editing is available for local
file versions only". Share the remote target construction between both entry
points so the header edits the same sidecar. Direct writes stay refused for
media-server files, as they already are for SACD images.
cc
Jellyfin stream urls now carry ApiKey and requests carry Token="..." in the
MediaBrowser header; neither form matched the redaction patterns. Add
patterns for ApiKey/api_key query values and a quoted Token value, and
register the stored Jellyfin token with the literal redaction layer whenever
media-server settings are bound or saved.
cc
Self-hosted servers often run on stable distributions and lag behind
upstream releases. The opt-in script starts one disposable Jellyfin
container per version on 127.0.0.1, completes the startup wizard through the
API, serves a synthetic 24-bit/96 kHz FLAC library with an embedded cover
and runs the qbz-jellyfin live suite plus the qbz-source sweep-to-audio test
against it. It also records whether the server still accepts the legacy
token header.
The default matrix 10.8.13, 10.9.11, 10.10.7, 10.11.11 and 12.1 passes.
cc
Jellyfin 10.8 through 10.10 write the minDateLastSaved SQL clause against
the @MinDateLastSavedForUser parameter while binding @MinDateLastSaved.
Sent alone, 10.9.11 and 10.10.7 answer HTTP 500 to any date, so every
incremental Jellyfin sync after the first failed; 10.8 carries the same
typo. Both parameters filter the item's DateLastSaved on every version, and
10.11.11 and 12.1 apply them as the identical filter, so send both with the
same value.
The existing future-date live test could not catch this. A new one requires
a year-2000 delta to return the whole library; it failed with HTTP 500 on
10.10.7 before the change and passes on 10.8.13, 10.9.11, 10.10.7, 10.11.11
and 12.1.
cc
Jellyfin 12.0 disables legacy authorization by default and its migration
turns it off on upgraded servers (server PR #15559). Sign-in still worked,
but every later request carried the token in X-Emby-Token, so the first
library read answered 401 and the panel reported "Signed in, but library
access failed" (#502).
Send every authenticated request with Authorization: MediaBrowser ... Token
and the install's persisted DeviceId, percent-encode the values the server
URL-decodes, stream with ApiKey instead of api_key, and read libraries from
/UserViews with a 404-only fallback to the obsolete /Users/{id}/Views route
for 10.8. Both modern forms are read by every server since 10.8.
A socket fixture now pins the header shape, the absence of legacy headers
and query keys, the views route and its fallback. Live suites pass against
12.1.0 and 10.11.11, and a token issued by 10.11.11 keeps working after the
same server is migrated to 12.1, so stored sessions need no re-login.
cc
Closes#790. TrackRow defaulted clickPlays to true and only the album view
turned it off, so playlists, search, the queue and the library started
playback on a single click with a pointer cursor while the album page needed
a double click.
Desktop track rows now share one policy: the play disc plays on its first
click, a single click on the row body does nothing and a double click on any
part that is not a control plays once. Artist and album links react only over
the drawn name, so the whitespace beside them belongs to the row body. Dead
and play-blocked rows stay inert on double click. The same policy covers the
local row gutter, purchase rows, track rows in the library feed and the
immersive suggestions list. Kiosk rows keep tap-to-play because touch has no
hover play disc.
Drag, select-mode Shift/Ctrl toggling and delegate recycling keep their
existing paths. A new QML suite drives real clicks on the row components
(17 cases, 7 failing before the fix) and joins the runtime gate and
test-crates workflow. Verified by smoke test.
cc
Integrate bounded audio acquisition, transport handoff guards, signed audio
settings, remote volume capability enforcement and available-master cache
reuse. Preserve the independent PCM conversion commit for selective rollback.
The resulting tree matches the release binary verified by regression tests
and the local DAC smoke. Keep the existing session-resume interaction after
QConnect playback documented as a separate follow-up.
cx
Record the successful request separately from the preferred quality and the
returned format. Preserve explicit fallback limits across playback and
prefetch, retain provenance when spilling to disk, and read old sidecars
conservatively without inventing acquisition history.
Cover available CD/96 kHz masters, fallback and authentication boundaries,
file identity and sidecar versions. The regression fails with the previous
predicate; 299 core tests pass. Playback smoke confirms CD-quality reuse
from disk after acquisition at the maximum requested tier.
cx
Preserve remote capability metadata, encode device updates with the required
wrapper, and reject disallowed volume or mute commands before state changes
and reports. Keep Qt controls locked until the peer grants control, cancel
active drags when permissions change, and advertise the same policy used by
the local ALSA renderer.
Validated with protocol, application and Qt regression tests plus iOS/web
controller smoke against ALSA Direct.
cx
Use binary scale factors with explicit saturation for signed 16-bit and both 24-bit ALSA layouts. Cover every representable S16 and S24 value, non-finite input and byte-exact round trips through the real decoder. Keep the S32 conversion unchanged and isolate this adjustment for independent rollback.
cx
Serialize renderer execution before acknowledgement, validate queue occurrences and merge deferred transport fields without reviving stale targets. Carry play generations, initial pause and offsets through both hosts and source installation. Complete partial ALSA PCM writes with bounded retries and cancellation, with regression gates for handoff and writer progress.
cx
Pair cached audio with the requested and resolved quality, reject incompatible or unverified entries, and enforce disk budgets at startup and admission. Reuse the same policy for local, gapless and external playback while assembling CMAF segments in order with bounded concurrency.
cx
Allow negative numeric CLI values without accepting unknown options. Return an error when an audio setting has no persistence handler, and cover parsing and stored normalization targets.
cx
The Wallpaper background blurred the desktop behind the window by a fixed
0.75. Its options box in Settings > Appearance now has a Wallpaper blur
slider, shown for that mode only, from 0 % to 100 %: Qt's documented range
for MultiEffect.blur, 0.0 (no blur) to 1.0 (full blur), at the field's
blurMax of 64. The default stays 0.75, the look the mode shipped with.
Dragging moves the live background through QbzShell.wallpaperBlur; the
value is written to ui_prefs once, on release (the slider's `released`),
as a two-decimal number, and clamped to Qt's range on read. The Wallpaper,
blurred mode keeps its fixed atmosphere look.
The label and description are in all eight catalogs.
cc
In Settings > Appearance the rows that exist only for one theme trailed the
whole THEME group: the Custom editor and the Auto source rows came after
Album header gradient, Compact header and Dynamic background. With a
Wallpaper background, Background image then ran straight into the Custom
editor and read as one of its options.
Each choice's options now follow their own picker inside a SettingsFieldset:
a bordered group with a header whose chevron folds it (> folded, v open).
The Custom and Auto theme options sit right under Theme, Custom starting at
"Start from current theme", and the Wallpaper background's Background image
sits under Dynamic background. The rows themselves are unchanged.
Each box keeps its folded state across restarts through settingsBool and
ui_prefs, the way the sidebar sections do (theme-custom-collapsed,
theme-auto-collapsed, ambient-options-collapsed; default open). Folding the
Custom box also closes an open color picker.
tst_settings_fieldset covers the rows inside the border, the header click
and keys, the settings below moving up, and a row click not toggling.
cc
Branch S of the 2026-08-03 cortinilla parity contract, signed off then and
never merged. Its Slint half edited crates/qbz, which pre-release has since
retired; those files stay deleted, and only the qbz-app changes land. The
three search files had not moved on pre-release since the branch forked.
qbz-app library tests pass (295) and the qbz-qt release build is clean.
cc
The folder rail's tick state counts the selected tracks under each folder
by path prefix, `<folder>/…`. SACD tracks are selected under their virtual
path `sacd:<image>#N`, which never starts with the folder path, so a folder
holding ISOs showed an empty or partial tick while its tracks were
selected; the bar's count was right. Visible after a Shift range over a
folder of SACD images.
A folder now also counts `sacd:<folder>/…`, the image's tracks below an
ancestor, and `sacd:<folder>#…`, the tracks of the synthetic folder node
whose path is the image itself. Each form is one binary-searched range of
the sorted selection, so the per-publish cost stays the same.
Tests cover ancestors, the image node, mixed files and images, and a
sibling whose name extends the folder's.
cc
This workstation configures ONE Cargo target outside the checkouts for every
worktree below the project directory. A build in any worktree therefore
replaced the `qbz` another worktree had just built: a parallel session's
release build in its own worktree replaced the binary under test in the
main one.
scripts/qt-target.py now decides where builds go. When the target Cargo
resolves lies outside the worktree and nothing pins it (CARGO_TARGET_DIR or
CARGO_BUILD_TARGET_DIR), the worktree builds in its own sibling directory,
`<target>-worktrees/<worktree>`; rebuilding the same worktree still replaces
its own binary. Cargo's default target inside the checkout (macOS, CI,
Windows) and explicit overrides are untouched, and no machine path is
committed. Two checkouts that share a directory name get a stable suffix
instead of one target.
qt-run.sh, the runtime gate and direct qt-cargo.py builds and tests all use
it. qt-run.sh prints the target and the binary path, and re-points
crates/target after each successful build, never over a real directory.
The aarch64 scripts build into and install from the same target, and the
qbzd acceptance script names the build command that lands its binary there.
The first build of a profile in a new worktree target is seeded from the
shared target with copy-on-write clones, skipping incremental caches:
Cargo rewrites moved build-script paths through root-output
(custom_build.rs, "rewrite paths if the target directory has been moved"),
so unchanged crates are not compiled again and no data is duplicated.
Without clone support nothing is copied and Cargo builds from scratch.
The resolution and shortcut handling start from the uncommitted draft in
the build-storage-layout worktree. Verified on the main worktree: seeded
from the shared release target, Cargo found everything fresh in two
seconds, and crates/target now points at the worktree's own target. 33
build-gate tests cover derivation, overrides, name collisions, linking,
seeding and the runner end to end.
cc
The AppShell hotkey seam duck-types selectAll, exitMultiSelectMode and
multiSelectOn on the mounted view. The offline manager exposed none of
them, so Ctrl+A did nothing there although the Slint ticked every track,
and MixView lacked multiSelectOn, so Escape never left its select mode. The
seam's comment, which still listed Artist, Playlist, Mix and Label as views
without multi-select, now names the views that implement it.
cc
A facet column already took Ctrl as "add or remove one" and a plain click
as "only this". Shift now picks the run from the last clicked value and
Ctrl+Shift adds that run, the file-list rule (SelectionModel.nextExclusive),
each column over its values as listed. A column whose values are rebuilt by
a change upstream starts without an anchor.
cc
A row or checkbox click toggled one folder. Shift now adds the run over the
rows the filter shows, Ctrl adds one, and the ids stay numbers for the
removal action.
cc
The manager's checkboxes toggled one track at a time. They now follow the
shared rule over the track rows as drawn, album headers excluded, and a
Shift or Ctrl click on a track row selects instead of starting playback; a
plain click still plays. The Slint had this surface on its range table
(SURFACE_OFFLINE).
cc
The rail's selection lives in Rust as a set of file paths, so it had no
anchor and stayed toggle-only (750c59034 said as much). The rail now keeps
the anchor path and, on Shift, sends every drawn row from it to the clicked
row to a new treeSelectRange, which SELECTS: a folder with its whole
subtree, a track with its own record, so a range only ever adds. In select
mode a Shift or Ctrl click on a row body selects it instead of opening the
folder; a plain click still opens it.
The rail's selection mutations used to be spawned side by side. With ranges
that became a race: a range that finished before the click that set its
anchor would be undone by that late toggle. They now run one at a time in
click order, with select-all, clear and leaving select mode in the same
queue.
cc
A collection keeps its selection in Rust by persisted position, and
detailToggleItemSelect only toggled. It now takes `shift`: a plain or Ctrl
click toggles and becomes the anchor, a Shift click adds every row between
the anchor and the clicked one in the order the rows are DRAWN (filtered and
sorted, not the position order), and the anchor is dropped wherever the
selection is cleared. The list rows, their checkbox and the grid cards pass
Shift through; the kiosk list passes false.
In select mode a row's artwork still started playback and its title and
subtitle still opened the item or the artist. They stand down now, so the
click selects.
cc
"Play selected" arms a select mode over one album section, and its cards
only ever toggled one at a time. Shift now adds the run from the last
clicked card in the section's current sort order and Ctrl adds one, through
the same SelectionModel every other select-mode list uses. The anchor drops
when a section is armed or disarmed.
cc
Excel-style selection (750c59034) read the modifiers only where a row BODY
took the click. Every other target a select-mode surface draws dropped them,
so a Shift-click there was a plain toggle:
- the track row's checkbox disc passed Qt.NoModifier;
- QbzCheckbox and SelectCheck emitted toggled() with no argument, so the
Library album rows, the local album rows and the folder rows, which
already expected `function (mods)`, received undefined;
- AlbumCard emitted selectToggled() with no argument, and the paged local
albums grid (299dcde5b) spelled the drop out as Qt.NoModifier.
Some targets were never selection targets at all: in select mode the artist
and album names on a track row, the artist and label on a Library album row
and the artist line of an album card still navigated, leaving the page and
the selection mid-gesture. The row links stand down in select mode so the
click reaches the row body with its modifiers, and the card's artist line
toggles like its artwork and title. A hover-only tooltip on the pulled but
cached glyph no longer eats the row's click.
Reproduced on the release binary in a nested KWin with injected input: in
Local Library Tracks, the checkbox discs of rows 1, Shift 4 and Ctrl 6
selected 3 rows; the same clicks on the row bodies selected 5. No list
view, Flickable or wheel code changes; tst_wheel passes unchanged.
tst_multi_select clicks each of these targets through the real TrackRow,
AlbumListRow, AlbumCard, QbzCheckbox and SelectCheck.
cc
controls/SelectionModel.qml kept the anchor in a string property and then
looked it up with a strict comparison, so a list whose rows carry numeric
ids never found its anchor again and every Shift-click there degraded to a
plain toggle. Ids now compare as strings, which is what the selection map
keys already were.
The range fill also wrote `m[rows[i].id]` for every row in the span, so a
list that mixes group headers into its rows gained an "undefined" key and a
count one above the ticks on screen. Rows without an id are skipped.
`idKey` names the identifying field for rows that do not call it `id`.
scripts/qml-tests/tst_multi_select.qml starts with the rule's own cases and
joins qt-runtime-gate.sh and test-crates.yml.
cc
The Wallpaper mode's blur goes from 0.6 to 0.75 of MultiEffect's 64 px
maximum (about 38 to 48 px), so the picture behind the window stays
recognisable while competing even less with the text.
cc
The field's platform flag was named `onWayland`. With no `wayland` member
beside it the binding happens to work, but the on-prefixed property audit
(which qt-run.sh runs) rejects any `on<Upper>` name, and an instance
cannot assign it: `onWayland: false` is parsed as a handler for a
nonexistent signal and the component fails to load. It is `isWayland`
now, writable so a test can take the Wayland branch under the offscreen
platform, and a change of it also asks Rust to follow the window.
tst_wallpaper_field covers the Qt position on X11 / macOS / Windows, the
compositor rectangle picked by this window's size on Wayland (a
miniplayer-sized window is ignored, a move is followed), the centred
fallback, and an invisible field that reads nothing. It runs in the
runtime gate.
cc
Two more fields kept text across openings. The log viewer's search box and
the filter it drives both outlived a close, so the modal reopened already
filtered; opening it now clears both. The playlist manager's Move to
folder menu reset its query but not the box, which QbzLineEdit re-seeds
only while unfocused; it now resets the box too.
The remaining text fields in modals, popups and menus were checked and
already reset on open (genre filter, playlist picker, disc metadata,
replacement search, DAC wizard, sidebar row menu) or are read-only.
cc
The Listen list and the sidebar queue now reach their views through
QbzKeyedModel. In the Listen list a removed track fades out while the
rows below slide up, "play next" slides the queue open, a history row
trimmed above the window leaves the rows on screen where they are, and a
committed drag lands the row where it was dropped while its neighbours
slide (the chevrons keep the animated swap). Identity is the track
(source + id, repeats by occurrence); phase, section and phaseIndex are
data, so a track ending updates rows in place and moves nothing. The
follow-playing position and the cover window now run after the model
holds the new rows. A search is a different list and is replaced at once.
The sidebar's Up Next and Recently played are Repeaters in Columns, which
have no remove transition: a leaving row disappears and the rows below
slide up, a joining row fades in, and a page or search change is laid out
synchronously without animation. Drop slots count the Repeater's own
items.
tst_keyed_model gains the Repeater-in-a-Column case (silent first page and
page change, sliding rows that keep their items, fading insert).
cc
Removing an album (or a track, artist or playlist) from the Library made
Rust republish the feed, the derive produced a new array, and every body
bound to it took a model swap: the grid jumped to the top, every card was
rebuilt and every cover faded in again. All four bodies (grid, list,
albums list, playlists list) now share one QbzKeyedModel over the same
derive: the leaving card shrinks and fades while its neighbours slide
into the gap, the page stays where it was, and surviving cards keep their
delegates and decoded covers. The Artists sidepanel rail gets its own
keyed model with the same page identity.
A different page is still a different page: tab, sort, search, source
filters, genres and grouping form the model's scope and replace the rows
at once, from the top, as before. A heart removed under "Favorites only"
is not a scope change, so that card animates out.
Supporting changes: the artwork window reports read the model's published
rows and count from originY (a removal above the window moves the
origin), scroll memory is origin-relative, the playlists and albums lists
are height-gated like the grid so hidden bodies build nothing, pooled
cells come back opaque, and a background reload with a page already on
screen (a bulk removal, a language switch) keeps that page instead of
blinking through the loading skeleton.
tst_library_keyed mounts the real grid cell over the keyed model and
checks that an album leaving through a republished feed keeps the other
cards, their covers and the page offset; bound to the plain array it
fails.
cc
Handing an item view a new JS array is a model swap: every delegate is
thrown away, the scroll offset resets and the page is built again.
QbzKeyedModel takes the same arrays and reconciles them by key into
removes, inserts, moves and in-place updates, so a view keeps its
delegates, its offset and its artwork, and a leaving row can fade out
while its neighbours slide into the gap.
Each model row carries only `rowKey` and `rowRev`; a delegate looks the
row up by key, so it reads the host's own JS object (no copy into a
ListModel map) and a rev bump hands it a changed row. New objects for an
existing key are compared field by field, so a republished document with
the same data touches no delegate. A reorder keeps the longest in-order
run steady and moves only the rest; past a cap, and on any scope change
(tab, sort, search), the rows are replaced without animation, laid out
synchronously and started from the top, like the swap they replace.
Three shared transitions (add, remove, displaced) give every adopter the
same motion. The remove transition restores opacity and scale as its last
step: the view pools a delegate the moment the transition ends and hands
it back as it was left, which measured 76 invisible rows after three
removals and a scroll.
tst_keyed_model covers order and identity under 400 random edits, the
one-move reorder, silent resets, a removal that keeps the viewport and
creates at most two delegates, the fade that keeps its content, and the
pooled rows; it runs in the runtime gate.
cc
"Wallpaper" now behaves like a window that sees straight through to the
desktop: it always paints the part of the wallpaper that lies under the
window, wherever the window is moved, with a stronger blur so nothing in
the picture competes with the text. X11, macOS and Windows already told
Qt where the window is. Wayland does not, so on KDE Plasma a tracker
reads the window's geometry from org_kde_plasma_window_management on its
own client connection and thread (wayland-client + the plasma protocol
crate, pure Rust backend). It follows this process's windows only,
drops every other window's object after its initial state, and
republishes only when the geometry list changes; the field picks the
window by size and ignores updates while another background is showing.
Where the protocol is not offered the crop stays centred and one line is
logged.
KWin hands that protocol only to a binary whose desktop entry names it by
an absolute Exec and lists it in X-KDE-Wayland-Interfaces, so the Linux
desktop entries now carry both (the deb/rpm /usr/bin/qbz symlink resolves
to the same binary). The AppImage step restores the bare Exec name that
linuxdeploy resolves inside the AppDir, and the Nix package rewrites Exec
to its store path.
"Wallpaper, blurred" is Blurred art with the wallpaper in the cover's
place, so it now drifts while the transport plays and holds still when it
does not.
cc
QbzLineEdit re-seeds its box only when `text` CHANGES and only while the
box is not focused; QbzTextArea only while unfocused. Modals that reset
their drafts on the close edge (the box still holds focus for that
frame) or reopen with the seed they closed on produced neither, so the
box kept the previous input while the draft said otherwise. Both
controls gain `reset(value)`, which writes the box directly, and every
modal form calls it from its open handler: playlist edit and create,
MyQBZ create / rename / description, folder create and edit, the link
resolver, the rip wizard and the library folder editor. The MyQBZ
create name now resets on the open edge, and its edit panel re-seeds on
every open, also for the same collection.
Renaming a Qobuz playlist also patches the playlist manager's warm cache,
which the editor seeds from before any fetch: reopening the editor right
after Save showed the pre-save name until the background reload landed,
and forever while offline.
cc
The playlist view's multi-select toolbar gains "Remove from playlist" for
the owner of the open playlist, behind a confirm modal that names the
count. One dispatcher (`playlist_remove_tracks`) routes the picked row ids
by the open document's kind: a local playlist removes by stored position
(descending, one blocking pass, then reload); a mixed playlist drops its
sidecar rows and batches the remaining catalog ids into a single
`remove_tracks_from_playlist` call with an optimistic document patch.
Row positions are resolved before any delete so the compaction the local
repo performs after each removal cannot shift a later target.
cc
queue_qt::save_as_playlist skipped every local and ephemeral row and,
with no catalog ids left, returned without a word — a queue of Local
Library tracks opened nothing from either the queue panel or the Listen
list, online or offline. The module's own note said the picker's local
mode existed and only the row-to-ref resolver was missing.
local_playlist_qt::local_picker_ref_for_queue_track now builds that ref
from a QueueTrack by source (a file its row id, Plex its rating key,
Jellyfin/Subsonic the server item id, an offline copy its row id from
the hint slot; an ephemeral row nothing), and the queue carries ids and
refs together: only local rows open the picker in local mode, a mix
carries the new Payload::Mixed, which the picker routes as the two halves
would be on their own — both writes onto a local playlist, the sidecar
plus the API (duplicate check included) onto a Qobuz one, a local
playlist on create while offline. The per-row "Add to playlist" entry
shows for local rows on both queue surfaces too.
cc
"Dynamic background" gains "Wallpaper" and "Wallpaper, blurred". The
picture is the desktop's wallpaper — Plasma's appletsrc (the containment
on screen 0, image plugin only; a wallpaper package resolves to its
largest bitmap, a slideshow folder to its first image), GNOME through
gsettings (the dark variant under a dark scheme), Windows' current
TranscodedWallpaper, macOS through System Events — or an image the user
picks (Settings > Appearance > Background image), which outranks it. The
resolved path and its mtime are memoised, so the frequent refreshes
(session entry, a mode switch, the window coming to the front) republish
nothing while the file holds.
"Wallpaper, blurred" sends the picture through the same atmosphere pass
Blurred art gives the cover, held still. "Wallpaper" shows the part of
the wallpaper that lies behind the window, lightly blurred under the
half-alpha chrome, at most a screen-sized texture: the window's place on
the screen comes from Qt where the platform tells it (X11, macOS,
Windows); Wayland hides window positions, so there the crop is centred
until the Plasma window-management protocol is wired. Neither mode waits
for a playing track. Six new strings in eight catalogs.
cc
QbzPrimaryButton animated its fill with a NumberAnimation; a colour
interpolated as a number lands on black, so every Add / Save / Create
button in the modals went dark on hover and its label vanished with it.
A ColorAnimation interpolates the two accents.
cc
From 960px up the box takes a quarter of the window width, between the
256px it was designed around and a 480px cap — on a 1720px window that
is 353px beside the header nav, where it used to be 196px. Small windows
keep the fixed width, the nav still takes its 60px when it lives in the
header, and the fit rule reads the box's real edge, so the nav goes
compact by itself when the wider box leaves it no room.
cc
StyledText underlines every `<a>` anchor unconditionally and nothing
else in the app underlines a link. The names are now colour spans in
the same StyledText (elision kept), and the click and hover hit test
measures the prefix up to each name with the row's own font, so each
name still opens its page or its search on its own.
cc
The tier badge alone reads as a mark, and its hover tooltip never shows
on these rows because the row's MouseArea takes the hover. The exact
tier ("24-bit / 96 kHz") now sits beside the badge — it is the decision
aid the list exists for.
cc
Both inline fields were bare TextInputs: no clear cross, no Escape.
They are QbzSearchField now — Escape empties the filter, hands the
keyboard back and folds the field into its title; the cross empties it
and keeps the caret. The control grew what a 22px header row needs: an
optional leading glyph (the toggle already shows one), a cross sized to
the field, and an `escaped` signal for hosts that fold the field away.
cc
The TextInput spanned the whole box, so a long query ran under the
cross and the Enter hint. It now ends at whichever of the two is
showing, and TextInput scrolls the text inside that width.
cc
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.
QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.
cc
Both queue surfaces (the sidebar panel and the Listen list view) gain the
two entries, routed by source the way the local rows already do: a
catalog row opens the album through its catalog id, with its title and
artist riding along as the unavailable-page hint, and the artist by id; a
local row (file, Plex, Jellyfin, Subsonic) opens the album through the
queue's navigation key — the key local_playback stamps so it opens the
same page the card does — and the artist by name through the Local
Library Artists tab. A row without the key or the name shows no entry.
cc
Under the ambient field some controls still painted the opaque theme
token while their neighbours used the half-alpha one: the album track
filter and its select toggle, the Library tab strip's host, the Local
Library icon selects, the header's active nav items and open playlists
button, the queue sidebar's now-playing card and artwork placeholders,
the Listen list's floating actions, and the shared SettingsButton,
IconTextButton, multi-select bar, text area, load-more pill, tree row
selection, sidebar count chips, Purchases tab strip, play-history filter
box and Orbit banner. Each now takes the ambient-aware fill the header
search and QbzTabBar already use (theme.ambientOn ? …A50 : …).
Tooltips, popups and modals stay opaque on purpose: they are read over
content, not chrome.
cc
CardMenu's pair-hover check read the popup's HoverHandler, which lives on
the background and never fires over a row: the rows' MouseAreas take the
hover events, so the panel only counted as hovered on its 5px padding.
Crossing from that padding into the child's first row read as "left both
menus" and the 350 ms leave timer closed the pair — the Copy submenu
closed the whole context menu as soon as it was entered.
A menu is now hovered while its panel OR one of its rows is (one hot row
at a time, held by reference so a destroyed row cannot leave it stuck),
and the pair check reads that on both menus. scripts/test-card-menu-qml.sh
drives the pointer through the parent row, the child's rows, back to a
plain row and out, and joins the runtime gate and CI.
cc
The startup pref only knew the last ROOT view: `last_view` lives in the
ui_prefs.json the Slint build shares, whose vocabulary has no room for a
detail, so a session closed on an album came back on Library > Releases.
page_restore_qt keeps the page itself in a per-profile document
(last_page_qt.json: root, view, args, state), written as the user moves.
Openers that can rebuild their page record their arguments through the
new nav_qt::record_with (album, artist, playlist, label and its releases,
mixtape/collection detail, award and its albums, mix, artist releases,
discover browse); the Settings section, the search query and tab and the
purchased album are noted where they live; the mounted view's tab comes
from QbzShell.reportNavState. At startup the shell mounts that page's
view (no Home flash), the history is seeded with the page's root so Back
leads there, and the session entry re-runs the opener — one-shot, behind
the same gates as the local album restore (remember pref, crash chain,
launcher link, kiosk), skipping catalog pages while offline and Purchases
unless it is switched on. Pages only their opener could rebuild (scene,
musician, disco builder, metadata editor) keep the previous restorable
page on record; local albums stay with local_restore_qt.
ContentRouter now reports the active tab of any view that has one and no
richer state of its own (Discover, Library) and applies a Back/Forward
destination's saved tab, so tabs survive both a restart and the history.
cc
Typing in the album track filter lost the keyboard on every keystroke. A
qt.quick.focus trace named the thief: a fresh track delegate took focus
"in scope pageFlick" right after each rebuild. In Qt 6.11 assigning a new
array to ListView.model after completion runs
QQuickItemViewPrivate::connectModel, which forces currentIndex to 0, and
updateCurrent() then focuses delegate 0 — inside the view's focus scope,
where the header's TextInput lives. `currentIndex: -1` does not survive
the assignment.
QbzArrayModel (a DelegateModel) now owns such arrays: a swap reaches the
view as remove+insert changes, which honour currentIndex -1 and keep the
rows. Because ListView re-seats inserted rows around the viewport, the
model re-expresses the viewport relative to the content origin and
restores it after layout; a page replacement calls scrollToTop(). The
album page, the kiosk album and the kiosk playlist swap their rows on it,
their ScrollMemory stores origin-relative offsets, and opening another
album resets the track filter (the box mirrors `trackQuery`).
scripts/test-array-model-qml.sh pins the behaviour (focus kept, viewport
kept, top on request) and joins the runtime gate and CI.
cc
Every search field follows one contract: Escape empties the text AND hands
the keyboard back to the shell root, and a clickable cross clears the text
while keeping the caret in the field. The header search shows the cross
left of the Enter glyph while the cortinilla is open; QbzLineEdit's search
mode and the new QbzSearchField (the album track filter and the playlist
filter) float it at the right edge.
Each field also keeps its focus after an edit unless another text input
took it, and logs when it had to reclaim it.
cc
Qobuz fills missing credits with strings such as "Not Documented",
"Unknown Artist" or "N/A". `qbz_text_utils::names::is_placeholder_name`
recognises them (case-insensitive, hyphen/underscore folded) and the album
document, the track info modal and the album info modal drop those names
from credits, composers, featured lists and the header's artist fallback,
dropping a row entirely when nothing legible is left.
cc
CardMenu entries declare a submenu as data (`submenu: [ {label, icon,
action} ]`); the row builds a child CardMenu on first use, opens it when
the pointer rests on the entry (or clicks it), and closes it when the
pointer moves to another entry. While either menu is hovered the pair
stays open; leaving both for 350 ms, pressing Escape or clicking outside
closes both. Picking in the child closes the parent too.
The track row, the Popular Tracks row and the player bar's "+" menu
declare their Copy entries this way instead of re-opening a second
CardMenu at the row's saved click position.
cc