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
Unavailable tracks (8): playlist rows now say when the WHOLE release is
gone (`releaseUnavailable`, one probe per distinct album of the pulled rows
through the library's helper); TrackRow's album link and "Go to album" go
dark on it instead of opening an empty page. Every row that opens an album
passes the names it knows (`openAlbumFrom`), so when the catalog answers
404 the page names the release: AlbumView renders an "unavailable" arm —
heading, "This release is no longer available on Qobuz." — with the
best-ranked alternatives Qobuz still sells (the replacement flow's ranking,
exposed as `ranked_alternatives`) in a rail; a network failure gets the
same page with "Couldn't load this release." and no rail.
Copy (9): the player bar's "+" and the track row menus (shared TrackRow
and the artist's Popular Tracks rows) gain a Copy entry with a submenu —
"Track name" or "Track - Album - Artist" — through the shared clipboard
carrier; CardMenu marks `submenu: true` entries with a chevron and the
host reopens the second menu on the same anchor. Row menus keep both lazy.
Buy on Qobuz (10): the album header shows a store action when the catalog
says the release is purchasable and it is not already owned; the track
menus and the player bar carry the same entry for catalog rows and resolve
it at click time — the store page opens in the browser, or a toast says
the release is not sold. The link is `https://www.qobuz.com/album/-/{id}`,
which the store answers with a 301 to the localised page (verified).
cc
Library feed album items carry the catalog label id next to its name.
Library > Releases (list) gains a LABEL column between the item and its
quality — a link to the label page when the id is known, muted text
otherwise — hidden on the label page itself and on narrow kiosk rows.
AlbumListRow and AlbumCard menus gain "View label" (the card only where its
host passes the id: the library grid does). The library's release-wide
availability helper is shared with the playlist rows for the next change.
cc
The header's play was a filled 44px accent disc beside two plain 32px
outlined actions and butted the JUMP TO rule; it is now the plain form with
8px of air above. The Popular Tracks row menu gains "Track info" for catalog
rows through ONE lazy TrackInfoModal per artist view, not one per row.
cc
Every field of both modals is a QbzSelectableText — artist, label and
musician names stay links. A copy button beside the close cross opens a
flyout: "Copy basic data" puts "{Track} - {Album} - {Artist}" (or
"{Album} - {Artist}") on the clipboard; "Copy full" formats the whole sheet
as plain text — the track's metadata, credits and copyright, or the album's
header plus the ACTIVE tab: credits per track with performers, or the
review. Album credit row heads stay plain because their click expands the
row. The two new strings ship in all eight catalogs.
cc
The artist biography (and the album, label and playlist descriptions that
share this modal) sat in a 560 x 460 card with dense body text. The card is
now at least 75% x 65% of the window, the body is selectable rich text one
size up with 150% line height and spaced paragraphs, the scrollbar stays
painted, and a copy button in the header puts the whole text on the
clipboard with a check as the receipt.
cc
QbzSelectableText is a Text-shaped label the user can select and copy: a
read-only TextEdit with mouse and keyboard selection, the right-click Copy
menu, and a rich-text link for fields that navigate; its `raised` arm keeps
a plain shadowed Text for hosts on the ambient field. QbzClipboard is the
off-screen TextEdit copy carrier CommandBlock, SandboxSettings and the
lyrics flyout each grew on their own, as one primitive. QbzScrollBar gains
`alwaysShown` for surfaces that must read as scrollable.
cc
With Discord closed, every now-playing edge (two per track: the immediate
publish and the poll's) tried the IPC socket and the crate logged
"find_pipe: could not find pipe" each time, so a listening session read
as an error stream. A failed connect now sets a five-minute deadline,
logs one line at info, and later edges push nothing until it passes;
opting the integration in again clears the deadline. The crate's own
warning is demoted to error in the default log filter. Pure backoff rule
covered by a unit test.
The Discover preferences store also logged "unavailable" when its lazy
init ran before the session restored; that startup order is normal and
the handle is rebound on activation, so it is a debug line unless a real
user directory fails to open.
cc
The compact heading sized its title with the Text's own implicitWidth
while eliding it; an elided Text recomputes its implicit size after laying
out at the bound width, so Qt reported a binding loop on `width` on every
album open (measured in a 30-minute session log). A TextMetrics with the
same font gives the natural width without feeding the loop.
cc
SettingsButton, QbzToggle and QbzSelect declared their own `enabled` over
Item's, and ReactiveRingsPanel its own `palette` over Item's QPalette: Qt
flagged all four at start (propertyCache: "overrides a member of the base
object"). The shadowed `enabled` never disabled input by itself — each
control filtered clicks and keys by hand, and any child that did not would
have kept receiving events. The inherited property now drives the visuals,
the focus policy and the guards, and disables the subtree the way Item
does; the rings' colour list is `ringPalette`.
cc
The wider macOS gutter added for the Liquid Glass corner curve read as
excessive next to the 8 px the content pane keeps; one value on every
platform now, and the macOS-only theme token is gone with its comment.
cc
The compact icon form is now a fit decision, agnostic of the sidebar state:
text tabs while every label at the 11 px floor plus the tab chrome and
whatever trails the tabs (the playlists button a closed sidebar adds) fits
left of the search box, icons only otherwise — or always with "Compact
header navigation" on. A closed sidebar used to force the compact form by
itself; it now only adds one button to the measure, so a window with room
keeps its text tabs. The room is measured from the third sacred button,
not from the hidden row (a Row leaves a hidden child's x where it was).
The downloads ring and the separator + playlists flyout button move out of
the compact row into a trailing group shared by both forms, so a closed
sidebar with room keeps its playlists entry; the group hides outright when
it has nothing to show.
Compact section buttons now call NavFlyout.sectionClicked like the text
tabs and the sidebar rows do, so under the click-navigates opt-in a click
on an icon lands on the section's first entry instead of only opening the
flyout — Purchases was the one compact item that navigated.
Settings > Navigation describes the new rule in all eight catalogs (en
never carried the old entry). The Purchases navigation gate models
headerNavOn and the fit bit: 128 x 3 combinations, still exactly one
reachable Purchases entry.
cc
The bottom band is now NowPlayingBar.qml's root: full width, flush on the
window's bottom edge, surface-card @ 0.5 over the ambient field like the
HeaderBar, opaque surface-card otherwise. The bar layout (PlayerBar /
NowPlayingBarSmall) paints no background of its own and mounts inset by the
gutter on its left, right and bottom, so the bar's ends still line up with
the content pane's while nothing around it shows the raw field. Both
rejected shapes are documented in place: an inset plate over the raw field
(three sides of raw background, the only zone of the shell without chrome)
and a frame slab behind an inset plate (a bar glued onto a slab). A future
layout inherits the band and the gutter from the seam instead of carrying
either.
cc
Only the margin was asked for. The frame-tier backdrop behind the inset
bar read as a bar glued onto a slab; it is gone, and the bar floats over
the shell's own ground like the content pane's margins do.
cc
The editor gains a Folder row — the same folders the sidebar and the
manager file playlists into, "No folder" for the root — seeded from the
playlist's current folder and applied on Save through the manager's own
move verb, so an existing playlist no longer needs the sidebar's row menu
to be filed (#785). "Delete playlist" joins the sidebar row menu (own and
local playlists; sidebar entries now carry isOwner) and the playlist card
menu (own playlists), behind a shell-level "Delete playlist?" confirmation
that runs the same delete the editor's button runs and leaves the detail
page only when it was the deleted one (#776). Eight catalogs updated.
cc
Library > Releases (and All) "Date added" was position-based: every source
got a rank of index / len and the merge interleaved sources by that
relative rank, so a purchase from yesterday could sit behind favourites
from months ago. The favourites API carries favorited_at (never modelled)
and purchases purchased_at; local favourites keep their own stamp. Rows now
carry addedAt, the merge and release-sort.js order by it newest first, and
only undated rows fall back to the rank, always after the dated ones.
The playlist view's Default and Date added never sorted: they reversed
whatever order the rows were in (after a Title sort, a reversed title
order), which is why flipping the direction took two clicks and switching
fields kept the old order (#782). Rows now carry their loaded position —
Qobuz's insertion order is its added order — Default and Date added sort
by it, and the direction toggle reverses a canonical order.
cc
A LAN-only receiver — a BluOS player, another QBZ — was invisible until an
official app paired it into the session; that is the "it showed up once,
for a day" report. The Qt LAN runtime now browses _qobuz-connect._tcp
alongside its own receiver (same lifetime: Qobuz Connect enabled and
ready), probes every announcement that is not us, and the picker shows the
answers under "On this network" with a Pair action. Pairing mints the
delegated pair with /qws/delegateAuth for the renderer's app id and hands
it over with this controller's session uuid and become_active, exactly as
the official controllers do; the cloud then lists the device and its LAN
row drops out by uuid. scripts/qconnect-lan-fake-renderer.sh is a receiver
that never joins the cloud, for exercising this without a Node. Pending:
qbzd parity, the 5-minute retry backoff, become_active=false.
cc
Every now-playing bar (New / Classic / Large / Small) is inset by the same
8 px the content pane keeps to its frame — left, right and bottom — behind
a frame-tier backdrop, so the bar's ends line up with the pane's, nothing
in it touches the window edge, and the gutter reads as one continuous
frame with the sidebar rather than a band of raw background. The Large
cover dock lifts with the bar. On macOS the gutter is 16 px: Tahoe's
Liquid Glass windows round their corners far more than before (larger
still around a toolbar) and clip whatever sits inside the curve — the
cover at the bar's left end lost its corner — and Qt has no LayoutRegion
to nest content into the curve, so the margin is the fix.
cc
The full text tabs kept an 11 px label at every width. The size now comes
from measuring the labels: 11 px stays the floor (the tabs beside the search
box at the 1140 px breakpoint), 12 or 13 px when every label plus its chrome
fits in the room left of the centred search box — 13 px being what the
sidebar rows and the flyout entries use, so with space the two hosts read
the same. Measured rather than a width breakpoint because the labels are
translated and their count changes (offline hides the Qobuz sections,
Purchases is opt-in).
cc
The separate "Purchases in title bar" placement toggle is gone: with Show
Purchases on, the entry sits wherever the other sections are — in the
sidebar while the navigation lives there, in the header beside the full
tabs or the compact buttons otherwise — so it is never the one entry left
behind. The nav_tb_purchases pref and its bridge key are retired; the
Show Purchases description says where the entry goes.
cc
The bars' audio flyout keeps its switch; Settings > Audio gains the same
key with its two companions: a Target loudness select (-11 / -14 default /
-16 / -18 / -23 LUFS, a value set elsewhere shows as a display-only
"Custom (N LUFS)" entry instead of being re-mapped) and the Prevent
clipping toggle. Eight catalogs updated.
cc
A new audio setting, on by default: the normalization boost is capped so the
track's known peak stays 1 dB under full scale. Store column + migration,
portable in the settings bundle, shown in diagnostics, and settable from
qbzd (settings get/set audio.normalization_prevent_clipping).
cc
Audio, Playback, Appearance, Navigation, Local Library, Offline,
Integrations and Updates stay in the column; Blacklist, Storage,
Import / Export, Developer (and Orbit while enabled) move under an ADVANCED
disclosure row with a chevron. The fold is not persisted: it opens itself
whenever the current section lives inside it, and reads as active while
closed over the selected page. Appearance takes lucide's
paintbrush-vertical, Integrations unplug and Storage database — baked into
every tint and registered as masters. The kiosk selector follows the same
order, flattened. "Share logs" stays pinned at the bottom.
cc
Settings > Offline loses its "Clear cache" row: it ran the same
QbzOffline.clearAll() as the trash glyph in the Offline Cache Manager's
stats bar, so the whole cache had two entry points and one misleading text
("your downloaded albums are kept" — what is kept are the purchased
downloads in the music folder). The glyph now carries a hover tooltip (the
manager mounts the shared QbzTooltip overlay; the limit and folder glyphs
get one too) and its confirmation says exactly what goes — the live track
count and size — and what stays. The Tauri-era string leaves the eight
catalogs; the new ones enter them.
cc
Settings > Storage keeps only the caches that have no section of their own:
the artwork cache, the lyrics cache and the Plex mirror. The offline
downloads group goes back to Offline and the playback cache (memory profile,
usage, storage folder) back to Playback, since both configure the feature
they sit next to rather than disk in general. Rows and bridge verbs are
unchanged; the playback cache QML test instantiates PlaybackSettings again
(16/16) and the .po location comments follow the artwork rows to Storage.
cc
Offline downloads, the artwork cache, the lyrics cache, the Plex mirror and
the playback cache (memory profile + storage folder) move to a new Storage
section, each row keeping the bridge verb it had. Offline keeps the mode
rows, Playback keeps the behaviour rows, Plex keeps Disconnect.
cc
The boot trim alone let a long session grow the folder past the budget
again (an early release reached 1 GB). A trim now also runs on the
blocking pool every 16 MB of new originals, the budget comes from
ui_prefs image_cache_max_mb (100/200/500/1000 MB, 200 default), .img files
with no row older than an hour are swept at boot, and the settings
document carries the stats, the size options and a clear action.
cc
With featured performers shown, the line is one elided StyledText whose
names are separate links: the main artist opens its page as before; a
featured name opens its page when the album credits carry its id and the
search for that name otherwise. Rows send the names with ids resolved.
cc
Mark A, mark B, loop between them until cleared; local playback only,
never persisted, cleared on track change or a seek outside the section.
A 100 ms watcher reads the millisecond position and reissues the local
seek; the seekbar shows the section and its two marks.
cc
Both were rendered from a hardcoded English month table in Rust, invisible
to i18n. Rust now hands QML the raw timestamp / civil date and QML formats
it with Qt.formatDate(..., Locale.LongFormat) like the purchase views
already do.
cc
ImageCacheService::evict had no caller since 69ed61207 retired the Slint
app's spawn_evict, so ~/.cache/qbz/images grew without bound in 2.1.0 and
2.1.1. housekeeping() now trims it to the same 200 MB budget once per run,
in 64-row batches so the GUI thread is never parked behind the cache
mutex. evict is batched and covered by unit tests.
cc
Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.
Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.
Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.
cx
Support Flatpak portal updates and signed Windows MSI upgrades. Add the Linux/macOS installer for 2.1.2, release signing, translations and regression coverage.
cx
Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.
Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.
Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.
Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.
cx
GNOME Shell pairs a live window with its launcher through the Wayland app_id,
and Qt sends the executable's name (`qbz`) unless the application sets its
desktop-file name. Every Linux package installs the entry as
`com.blitzfc.qbz.desktop`, so the dock painted the generic icon while the app
grid, which reads the .desktop directly, showed the right one; the D-Bus
notification `desktop-entry` hint had the same gap. StartupWMClass covers
X11 only.
Set `QGuiApplication::desktopFileName` to `com.blitzfc.qbz` right after the
application exists (Linux only). Verified the mechanism on an Ubuntu
GNOME/Wayland VM: a `qbz.desktop` symlink made the dock icon appear.
cc
Two tag_editor_qt tests compared editor_directory() — the parent() of a
file inside std::env::temp_dir() — against temp_dir() itself. On Windows
temp_dir() ends in a separator ("C:\...\Temp\") and Path::parent never
yields one, so the strings differ and the Windows Qt gate failed on every
run since 2026-08-28, the v2.1.0 tag push included. Linux never saw it.
Both tests now use a created subdirectory of temp, which normalises the
two sides on every platform.
cc
A restored session runs `refresh_now_playing`, whose MPRIS push is the
optimistic `Playing` at position 0 — correct for the real play path, where
a stream is about to open and the poll loop's play/pause edge corrects it.
Nothing opens on restore, so `playerctl status` answered "Playing" from
boot until the first real play, and the sleep inhibitor was armed for a
paused app. Publish Paused at the resume position once the restore is done.
Verified: playerctl reports Paused on a restored, unplayed session.
cc
Copy link failed on GNOME + Flatpak: arboard's Wayland path needs the
wlr/ext-data-control protocol, which Mutter does not implement, so it fell
back to X11 — reachable natively through XWayland, unreachable inside the
sandbox ("clipboard unavailable: X11 server connection timed out"). KWin
has the protocol, which is why the failure never reproduced on Plasma.
Replace it with a C++ shim (cxx/clipboard.cpp) that queues the write onto
the GUI thread and hands it to QClipboard: the base wl_data_device protocol
every compositor implements, no extra sandbox permission, and the offer
lives as long as the application (the #514 lifetime rule, without the
long-lived arboard instance). arboard is dropped from the crate.
Verified on Plasma/Wayland and on an Ubuntu GNOME/Wayland VM with DISPLAY
unset (the sandbox condition), where the copy now lands.
cc
Both panels were fixed-width (240 / 300); playlist names elided on wide
monitors. Each panel's visible edge (the content pane's edge, 8px past the
panel — the bezel between them shares the panel colour) now carries a 5px
grab strip with a 2x28 notch, split cursor, and a one-shot accent pulse when
a drag lands on a snap point.
Rules live in `panel_resize.rs` (pure, unit-tested), the bridge applies them
live and persists on release (`ui_prefs` `sidebar_width` / `queue_panel_width`,
clamped on read):
- sidebar: 240..480 while open; below 200 it snaps to the mini rail, below 24
it closes through the same state the header button uses (with the Large-NPB
fallback); the mini rail reopens at 220 — a 20px hysteresis band so no
pointer x can flip open<->mini on every move (the flapping seen in the
2026-09-11 screencast). The stored width never drops below 240, so
reopening restores the last open width. A state snap during a drag still
animates: the bridge raises `sidebarSnapping` before the state change and
Sidebar's width Behavior honours it while the drag otherwise disables it.
- column: 300..600; below 260 it closes queue and lyrics as their X would.
With the queue in the column and the width parked at 600, the notch shows a
steady larger glow toward the content; pushing 60px further opens the
Listen List (queue-view) and the queue leaves the column, lyrics staying
if open. A lyrics-only column never pushes through.
The Large-NPB dock is untouched: it is mounted with absolute x/width in
AppShell and reads nothing from the sidebar width.
Verified: panel_resize 9/9, qmllint + the six qt-run.sh audits, release
builds v1-v4, four owner smoke rounds.
cc
Swim Deep's Artist page showed Deep Purple's founding year and members. Two
defects combined: `search_artist` emitted `artist:Swim Deep` unquoted (the
second word leaks into the global query; Deep Purple ranks first at 100,
Swim Deep second at 88), and `resolve_artist` fell back unconditionally to
the first result when no exact name scored >= 90. Name-keyed caches then
made such guesses durable and shareable across homonyms.
New `qbz-integrations::musicbrainz::identity` — pure decisions plus
orchestration over injected fetcher/store/catalog boundaries:
- query `artist:"<lucene-escaped name>"`, URL-encoded once;
- containment rule: exactly one exact-name candidate with score >= 90, else
None — the first-result fallback no longer exists;
- source direction (Qobuz artist id -> MBID): the page's own Popular-Track
ISRCs (max 3, guests excluded) vote among the exact-name candidates using
every credit of every recording that carries the ISRC; ties, conflicts and
failures fail closed;
- scene direction (MBID + catalog scope -> Qobuz id): a unique exact-name
Qobuz artist is provisional; several homonyms are split by their own
top-track ISRCs (max 2 each); no evidence omits the row instead of taking
max(albums_count);
- four `*_v2` cache tables keyed by the stable identifier + matcher version,
carrying evidence kind, expiring at read time (verified 30 d, provisional
7 d, search windows 7 d, ISRC credits 30 d); legacy name-keyed rows are
neither read nor written; scene cache key bumped to v2 with the real
account territory (no scope -> no cache), rows re-derived on replay.
Core: `catalog_scope()`, `musicbrainz_resolve_source_artist`, store/catalog
adapters; `musicbrainz_resolve_artist` and the musician resolver go through
the containment rule. Artist page passes {qobuz id, own ISRCs} to core and
keeps its generation guard; QML, playback, auth and reco crates untouched.
Tests: qbz-integrations 59 (query encoding, #768 ordering fixture, ISRC
voting incl. collaborations/dupes/conflicts, cache keys/TTL/version/legacy,
bounded fan-out with request counting), qbz-core 40, qbz-qt 782.
cc
Every album card producer copied `title` raw although the model carries
`version` ("50th Anniversary", "Deluxe"); the album page, info modal and quick
view already used `format_album_title`. The Artist page listed two identical
"All Things Must Pass" cards for two different editions.
- Route the 8 card producers through `album_qt::format_album_title`
(artist page buckets, album carousels, label grid, Home rails, Library ALL
feed, Artist Scene); search_qt drops its private duplicate of the helper.
- AlbumCard.qml: the title is always the full string, elided at the card
width; when Qt reports `truncated`, hovering shows the whole title in a
Controls ToolTip (QualityBadge pattern, no character counting, no ticker).
Verified: qmllint clean, qbz-qt suite 782/782, release build.
cc