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