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
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
"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
"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
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
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 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
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
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
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
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
Keep search, settings and link shortcuts usable while editing, dismiss focus on outside clicks and navigation, and guard deferred focus against hidden inputs. Add Qt Quick regression coverage.
Refs #753
cx
Preserve the remaining queue when shuffle is disabled, expose clear transport state tooltips, retain Library source filters for the session, and add independent track-artwork preferences for Queue and Library.
cx
Apply interface scaling through Qt at startup, complete keyboard navigation, move account migration into setup and progress modals, and defer Subsonic sync while the server scans.
cx
Preserve local playback when it is authoritative, make renderer conflicts explicit and persist the selected policy. Filter unsupported queue rows, restore portable Qobuz identities after disconnect, and fence takeover commands to avoid stale queue and playback loops.
cx
The ambient legibility scrim was a fixed BLACK layer at ambientDim — right
for dark themes, but over a LIGHT theme it pushed the field to mid
luminance, the worst ground for dark text, and the grey secondary/muted
ramp drowned. Light themes now veil with their own near-white surfaceMain
a notch stronger, with the in-shader (mode 1) and in-atmosphere (mode 2)
darkening turned off there; dark themes keep the exact previous look.
QbzTheme additionally steps textSecondary/textMuted toward textPrimary
while ambient is on under a light theme, so the weak ramp keeps a
contrast floor everywhere without call-site changes.
cc
Plain-Item overlays get no modal grab, so a MouseArea that swallows
presses still lets the wheel scroll the page underneath (the rule and
its wording live in DiscoverConfigModal.qml). Every scrim and panel
swallow across the modal set now accepts the wheel; the modal Popups
(TrackInfo, CastPicker, AlbumInfo, QconnectDev, LocalMediaInfo) already
had the Overlay grab, and CoverLightbox — a non-modal Popup — gets its
own.
cc
Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.
cx
Eight Playback actions the table never had, on Spotify's desktop defaults:
volume up/down (Ctrl+Up/Down), mute (M), shuffle (Ctrl+S), repeat (Ctrl+R),
like (Alt+Shift+B), and a surface-independent seek (Ctrl+Shift+Left/Right).
Each reuses an existing transport seam, and the immersive bare-arrow seek
rows are untouched. Volume honours the player bars' volLocked predicate, so
the hotkey cannot move the slider on an ALSA-Direct hw path where the engine
documents the write as a no-op. Like rides a QML seam, as nav.search does,
because the track id is a QbzPlayer property rather than part of the Rust
now-playing model.
Two new tests; labels added to all eight catalogs.
A startup modal carrying the release notes' "Windows -- yes, but read this
first" section verbatim: Windows is not a supported QBZ platform, the build is
as-is, and the port is open for adoption.
Persisted as two fields, hidden plus the acknowledged version, written in ONE
transaction and read from ONE parsed document. Two separate whole-document
read-modify-writes could leave a torn pair after a crash or a failed rename,
and either half alone re-opens the modal -- the user would tick the box and see
it again. ui_prefs.json is shared with the shipping Slint app, so the discipline
lives in settings_qt with the rest of that file's writers.
A version rather than a bool alone is what makes "reset it on every update"
need no reset step: a build whose version differs simply does not match. A
reinstall of the SAME version does not bring it back, which is accepted -- the
prefs file outlives the installer, and the MSI is where that would be cleared.
Focus is taken in Component.onCompleted, not onVisibleChanged. Item.visible
defaults to true and the Loader only creates this when it should already be
shown, so there is no false->true transition to observe: the handler would
never have run, Escape would never have reached it, and ordinary hotkeys would
have kept working behind the modal. Focus is handed back to the shell root on
destruction, because Qt strands activeFocus on an item being destroyed and that
kills AppShell's key dispatcher until the next click -- the failure
QbzConfirmModal already documents.
Mounted through a Loader gated on isWindows. `visible: false` still constructs
the whole subtree, and Linux and macOS have no reason to build a panel, a
Flickable and fourteen delegates for a modal they can never show.
The body is NOT translated, deliberately: it is the author's statement of
policy and the wording is the point. The chrome around it goes through tr.
cc
Promote metadata editing to a routed full-page workflow with tabular per-track fields, ordered artist credits, provider identifiers, and compilation handling.
Add bounded artwork selection and retrieval through MusicBrainz, Discogs, and Last.fm. Direct and sidecar saves preflight immutable rows, verify rich tag writes, cache covers by content, and update exact database ids transactionally.
cx
Restore the selected-version editor with sidecar-safe defaults, inspected tag layers, guarded MusicBrainz and Discogs lookup, and confirmed verified direct writes. Keep paths Rust-owned and reject stale or injected drafts.
cx
can be read
RIP WIZARD, three steps, each answering something the first version got wrong
by not asking. What will happen — which disc, how many tracks, and that the
output is FLAC, stated rather than implied. The tracks, editable, because a
CD-DA carries no titles at all so "named wrong" is the ordinary case and once
files are on disk it is a rename job. And what happens afterwards: a ripped
album the library cannot see is a folder full of files, so the destination is
compared against the registered folders — by path COMPONENTS, not by string
prefix, since /music-backup starts with /music and is not inside it — and the
right follow-up is offered, naming the folder it would re-scan. "Do nothing" is
an explicit choice rather than the silent default. The follow-up runs only on
success.
Tracks can be deselected. Everything is ticked on arrival and the user goes
from all to fewer; a wizard that starts empty makes the common case the one
that costs the most clicks. On the Rust side an absent `selected` means yes, so
an older form cannot silently rip nothing.
A PROGRESS PANEL behind the ripping chip, which is a door rather than a label:
it is the only thing on screen during a rip and the two questions it could not
answer are the ones that matter. A soft lock says not to eject — soft because
nothing here can hold a tray shut, so the honest move is to say it and keep
saying it. Per-track state with a bar on the row that is actually moving.
Stopping is offered, deletes nothing, and says so; a cancelled job does not
jump to 100 %, which is the one lie a progress bar must not tell.
CORRECT DISC DETAILS, for a CD and a SACD image and for neither on a folder,
which has no identity to write a correction under. The list shows what
distinguishes four pressings of one album rather than four identical titles,
and flags a track count that disagrees with the disc. Selecting is not
applying: the candidate is fetched in full and its track list shown, because
that list is the only thing proving a plausible title is the right pressing.
Applying renames the live session without renumbering ids — a re-open would
orphan a playing track — and writes a user row. Only MusicBrainz re-resolves
the cover; Discogs ids are not Cover Art Archive keys and a wrong cover is
worse than the old one. The correction can be dropped, because without an
escape a bad apply is permanent and that makes people afraid of the button.
THE PANE SPLITS BY DISC. A box set scans as one album — measured on a 34-track
set whose album_group_key is the box root for every file while the disc number
sits in the data unused — so the pane was a flat list 247 rows long. The key
carries the disc, unconditionally, because it round-trips through the play-album
lookup. Its own client-side filter over what is already rendered, since finding
one track meant scrolling past the other 246.
EPHEMERAL TRACKS LOSE WHAT CANNOT OUTLIVE THEM. queue_qt has published
isEphemeral on every row for a while and the queue panel respected it; the bar
never read it. The heart goes inert, the add menu keeps only its queue actions,
and the row menu comes back with those three and nothing else. Absent rather
than rendered-and-inert.
Also: a spinner on Open that blocks a second click onto a drive already being
read, held by a counting RAII guard because opening is a relay and the chip
must not blink off in the seam; the now-playing card routes a disc to the pane
it came from instead of to an album id no catalogue can open; the file pickers
use rfd, the platform's own dialog, which the POC-NOTE said was a five-line
change once the dependency existed — it existed, Settings had been calling it
from this crate for weeks, and on macOS the four Linux choosers do not exist so
Open folder and Open SACD image opened nothing at all, in silence; the empty
state stands down for the Open pane, which on a machine with no library drew
both fully painted; tooltips on every disc control through one hover() helper,
since two call sites had already mismatched their key, which does not fail —
the bubble simply never goes away.
cc
The sidebar "+" called create_playlist(), which made a playlist named literally
"New Playlist" and navigated to it. Four of the modal's five fields had no other
door, and one had NO door at all:
name hardcoded reachable in Edit playlist
description always empty reachable in Edit playlist
folder always root reachable in the sidebar row menu
public always false NOWHERE
offline-only from connectivity NOWHERE for an online user
That last row is why this counts as a regression rather than a rough edge.
offline_only decides whether the playlist is a local_playlists row
(local:<uuid>, library.db, no Qobuz account, never uploaded) or a Qobuz entity,
and the shortcut derived it from connectivity alone — so someone with a Qobuz
session could not create a local playlist without pulling the network cable.
Ported against CreatePlaylistModal.slint plus on_create_playlist
(main.rs:21107) and on_submit (:21496). The submit branches where the reference
branches, and a creation forced local by being offline KEEPS the flag, as the
reference's own note says, so Edit can unmark it later to enable "Upload to
Qobuz".
Two calls made here:
- Create rides the QbzPlaylistEdit singleton rather than getting its own, where
the reference splits them. Same domain — a playlist's own metadata — with two
documents that never interact, and a second QObject is a second boot() to
forget; that bridge's header already documents a missing boot() swallowing
publishes silently and forever.
- The folder picker hides on the offline-only arm. folders_qt::move_playlist
takes a u64 and a local:<uuid> has none, so the reference shows the control
and discards the value.
The shortcut is deleted rather than left beside the modal: a second creation
path that skips it is the fork the port rule forbids. All thirteen strings were
already in the catalogues.
cc
Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.
MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.
Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.
PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.
The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.
SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.
CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.
APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.
RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.
ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.
Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.
Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.
Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.
cc
Follow-up to 66ba12bd5, which fixed the coverflow but left two of its
neighbours and a gate that could not see either of them.
QueueTabsPanel built one ImmQueueRow per queue track when opened —
including every collapsed 0-height history slot, which on the reported
session was 1162 of 1935. The slot stays (its height is what makes the
collapsed-history arithmetic work); the row inside is now built only
within a viewport of the visible band.
SuggestionsPanel and AppShell's QueuePanel move behind Loaders. The
queue panel was never the freeze — its rows are paginated at 40 in
Rust — but a closed panel should not be constructed, and the audit
cannot know about a cap that lives on the other side of the bridge.
Gating it also let SuggestionsPanel drop the onVisibleChanged half of
its load hook: mounting IS becoming visible now.
The audit is the real content of this commit. Written narrow — only a
model fed by unbounded user data counts, because the wide rule reports
145 sites and a gate that noisy gets switched off in a day — it then
reported the pre-fix tree CLEAN. A gate that cannot catch the bug it
was written for is worth nothing, so it was tested against that tree
and fixed on two counts:
- CROSS-FILE. The `visible:` sat in ImmersiveView.qml while the two
Repeaters over the whole queue lived in CoverflowPanel.qml. There
is now a transitive index: a component is heavy if its own file has
an unbounded view or if it mounts something that does.
- MULTI-LINE. The coverflow's gate spans two lines, so a first-line
read saw `QbzShaderScene.scene === 0`, matched no known gate, and
skipped it.
With both, it finds all five mounts on the pre-fix tree, including the
two that froze the app. Known gates also grew past tab predicates: the
coverflow was keyed on QbzImmersive.mode.
Verified on a copy of the real session state with the 1935-row queue
restored: launches to a painted Discover, idle at 1.0% CPU.
cc
Reachable from a dead row's context menu, in a playlist the user owns. The
reference had this and shipped it with four defects; none are ported.
It was unranked -- Qobuz's raw relevance order shown to the human. The weighted
matcher that already lives in qbz-playlist-import does the ranking now, and its
ISRC short-circuit matters most: licensing churn usually republishes the SAME
recording under a new id with the SAME ISRC, and the pulled track keeps its
isrc (confirmed in the 2026-08-17 capture of album 0886443985094). That case
needs no human judgement, so the ISRC lookup runs first and pins its hit to the
top.
It removed before it added, so a failed add lost the track outright. The order
here is add, reposition, remove.
It did not preserve position -- the computed index went to a console.log and
the replacement landed at the end. It lands where the dead row was, through the
new updateTracksPosition verb. Every failure in that step is a warning and a
different success toast: the repair still stands at the end of the playlist,
exactly where the reference always put it. Position is a nicety, never a reason
to abort a repair.
Two guards. If the ISRC lookup answers with the dead track itself -- it can,
since a pulled track keeps its metadata and search still indexes it -- then
"replace" would be a no_duplicate no-op followed by a remove, and the user
would lose the row while being told it was repaired. And when the remove fails
the playlist permanently holds both rows: that is not rolled back, because a
rollback is a second write that can fail the same way and would delete the only
good copy. The list is refreshed so the user SEES both, and the toast says the
old one has to go by hand rather than claiming a reload will fix it.
cc
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.
ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
no onClicked, and a RoundedImage with no fit:, so the default crop blew an
800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
reference already has (Add/Change/Remove image, Open in browser, Save as…) and
the lightbox this port invented for albums and playlists. Custom images are
keyed by artist NAME into the same custom_artwork.json the Slint build writes,
so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
artwork_qt::cached_path consults the override FIRST, so coverMap held the
custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
is not: it leaves the percent-escapes in place, so any path holding % # or ?
came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
"View cover", since en carries none of that family and falls back to the msgid.
CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
their expansion into the full 55-value palette and the on-disk format all stay
in the shared qbz-theme crate, so a theme authored in the shipping Slint build
already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
because current_slug() re-reads it from disk — a seed taken afterwards would
resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
memoized for the life of the process and filled at bridge construction, so a
process that booted without the file would keep serving the OLED default even
after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
every drag frame with a truncating write, on a file the other build has open.
ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
a section plus a TOC entry, bold and backticks are stripped rather than styled.
Text.MarkdownText is a different subset and would have rendered a different
document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
to bump.
DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
carries about seventy, so every issue filed from a Qt build arrived with no
kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
sample-rate state and no playback context. The report is injected INTO
qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
not mapped, so their runtime column can only read "—", and their saved column
would come from a graphics.json this binary never reads or writes — foreign
state presented as this app's configuration.
SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
on declaration order and carried no z, so any z-bearing modal buried them: a
toast raised by a background event behind an opaque panel is never seen. They
now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
always-dirty, so the whole build script — per-shader qsb, the recursive asset
sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.
Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.
Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/
cc
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).
The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.
km
The Developer > Share-logs handoff (open::that on the log file) becomes a
filterable in-app view: level filter + substring search run Rust-side over
qbz_log's ring and only the survivors cross the bridge (one document, max
500 rows), with auto-tail, copy-all, a GitHub-ready <details> bundle and
paste upload. The bundle header now reports the real audio backend via
settings_qt::current_backend_label.
cc
AmbientField is mounted at :46 and ambientOn derives from QbzShell.ambientMode,
so the note claiming the artwork-derived background is unimplemented had been
false for some time. Corrected in passing because this port's diff touches the
file -- LESSONS-2026-08-03 L10 asks for exactly that rather than propagating it.
cc
Contract sections 4, 5, 6 and the QML half of 7.
ContentRouter.qml is the D3 extraction: ONE view-mount chain, shared by both
shells. The Slint kiosk's content block is a verbatim copy of AppShell's and
KioskShell.slint:10-17 names this extraction as the follow-up it was waiting
for. 23 desktop arms carried over with their comments, 8 kiosk overrides, and
the kiosk-only nowplaying route. AppShell's four viewLoader.item sites now go
through contentRouter.currentItem; a tree-wide grep confirmed no other
reference (LESSONS L4 -- grep, do not trust memory about "both places").
KioskShell.qml carries the back bar with the full search focus lifecycle, the
content-height arithmetic including the now-playing exception, the offline
gate, the forced small transport, the player-zone focus ring, the root key
handler and the 30ms initial focus grab. Two findings worth keeping:
- the NavRail tiles fire header-menu-navigate("favorites-tracks") in the
reference, which switches the view AND selects a landing tab; navigateTo
carries only the route, so the tiles reuse the port's existing pending-tab
handshake rather than landing on the wrong tab.
- the visualizer tile's action is a sanctioned no-op (R2). Wiring
QbzImmersive would be worse than nothing: `open` would go true over an
empty mount and the key guard would then reject every key against a
surface that renders nothing.
Three fractional font sizes had to be rounded. Qt's font.pixelSize is an INT
property, so the reference's 11.5px (NavRail.slint:47) and 12.5px
(KioskNowPlaying.slint:59,67) are not expressible; font.pointSize is the only
real-valued alternative and is DPI-relative, which would make those three
labels scale differently from every other in the tree.
The NavRail one was caught by the offscreen smoke run IN THE KIOSK PROFILE --
it made the whole rail unavailable, i.e. a kiosk with no footer. The build,
both QML audits and qmllint were all green while that was true, and the
desktop smoke was clean because it never mounts KioskShell. The other two
were still latent: KioskNowPlaying is not on the smoke's route.
Gate: build 9 warnings = baseline, 291 tests, both audits OK over 229 QML
files (+15), qmllint filtered = the same 20 pre-existing ArtistView lines and
nothing else, kiosk-profile offscreen smoke clean.
cc
Five geometry defects, all latent while the Qobuz-only panel held at most 14
rows and never scrolled, and all reachable the moment the local sections land.
- The height cap hardcoded 42 for the now-playing bar in every mode. The
bar is 42 only in NPB mode 2; everywhere else it is 112, so the panel
could run up to 70px into it. The cap is now mode-aware, reading the same
theme tokens AppShell uses. The header height literals in the scrims and
the panel anchor became theme.headerHeight at the same time.
- The click-outside scrims left a gap for the search field computed without
the 60px the field gives up when the section nav sits in the header, so
with nav-in-header there was a 30px dead strip on each side of the field
that neither dismissed the panel nor reached the input. The scrim now
tracks HeaderBar's own width rule exactly.
- Rows were 12px wider than their block: a QML Column's padding does not
size its children, and parent.width ignored it. clip hid the overflow,
but the highlight ran flush to the right edge while staying inset 6px on
the left.
- flat_index_content_y started at 6.0, mirroring the panel's 6px top
padding - which is a Flickable VIEWPORT margin, not content space. Every
row was reported 6px low, so scroll-into-view over-scrolled going down
and clipped the row's top going up. The immersive twin, written later
against the same layout, already started at 0.0.
Two unit tests now pin the arithmetic. There were none for the desktop
arm, which is why the 6.0 survived; the immersive arm had its equivalent
and was correct.
- The scrollbar used the primitive's default 14px gutter where the
reference overrides it to 10.
Plus the "Enter" affordance glyph (U+23CE + one space -> U+21B5 + two, as in
the reference) and the panel drop shadow, which reuses the immersive twin's
idiom - a mirror rectangle blurred by MultiEffect, skipped when there are no
shaders - rather than introducing a second shadow mechanism. NavFlyout still
has none and says so; that is its own parity pass.
AppShell's comment claiming the cortinilla is the "LAST child so it renders
above every surface" is corrected: four siblings are declared after it, and
that is fine.
Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4
(D-BUG-3/4/5/7, rows A5/A7/A10/A12/A13/A19/A28/A2, UT-1).
Gate: build clean, 258/258 tests (2 new), both QML audits OK, offscreen
smoke 0 complaints + home published.
cc
Root overlay self-gated on QbzImmersive.open with the load-bearing
click-blocker, 6s auto-hide with the VNC hover-storm delta guard,
fullscreen guard, exit paths 2-4, seek arrows, the header band (view
menu in Slint order, search field on the inert B1 surface, window
controls with the first FullScreen write in the Qt frontend), the
ViewModeMenu Immersive row live, Shift+I in Main.qml with the
text-input gate, QbzLineEdit fieldActive seam (D16). Panels are stubs;
B3/B4/B5 replace them. RFB evidence in scratchpad/immersive-b2/.
km
The release-section "Load more" fetched a page and appended it to a structure
no view ever re-read. The handler pushed IN PLACE into the object behind
`readonly property var artist: JSON.parse(QbzArtist.artistJson)`, and the
releaseSections projection returned that same array on the unfiltered arm, so
both Repeaters were re-handed the identical reference and nothing was dirty.
The tree's own rule, written in cards/PlaylistCollage.qml: rebind requires a
NEW object reference. A second bug sat on the same path - the artist document
republishes progressively (stories, then each MusicBrainz section as it
resolves) and every republish re-runs the parse, wiping anything appended, so
fixing only the first would have left a bug that looks random. Both
pre-existing; the in-flight placeholder added earlier is what made an
always-silent no-op visible.
Appended pages now live in an overlay keyed by release type. It survives a
republish, emits new arrays so the Repeaters rebuild, dedupes by album id,
carries the reference's 4-page cap per bucket (artist.rs:718-720, which hands
off to the discography page beyond it), and is guarded against a page
requested on one artist landing on another's grid - releaseSectionReady
carries no artist id and every artist has an "album" bucket.
"See discography" was a pointer-cursor MouseArea with no onClicked under a
POC-NOTE saying the page was out of scope. ArtistReleasesView.qml +
artist_releases_qt.rs port ArtistReleasesView.slint: infinite scroll rather
than a button, plus the loading, error-with-retry, empty and offline arms.
The album page's "More from <artist>" view-all is a second door to the same
page. Its paging offset counts what the SERVER returned rather than the
rendered row count the reference uses: blacklist filtering and id dedupe make
the rendered count smaller, so the reference re-requests seen rows and skips
the tail whenever the blacklist is non-empty.
The per-section sort was a dimmed Rectangle with no MouseArea, under a
comment asserting no seam existed. It is now a real QbzSelect with the
reference's five options and index mapping, persisted per release type in
artist_prefs. Choosing "Default" after a sort does not restore the server
order until the page reloads - that is the reference's behaviour
(album_map.rs:262 is a bare no-op arm and resort sorts the live model), kept
deliberately rather than diverged.
Also, two visual fixes from the same round that share these files: Popular
Tracks drew its cover at radius 8 where the shared rows/TrackRow.qml uses 4,
and the album page's rail mounts said `width: parent.width` inside a Column
with 32px padding - QML padding shifts children without narrowing
parent.width, so the rails hung 32px off the right and the right page chevron
landed outside the window.
Carries scripts/qt-diag.sh, which has been sitting untracked since the
rendering round: it answers "which backend is this actually running on" from
a real graphical session, refusing ssh and refusing offscreen because either
would answer the wrong question. That refusal is the point - the doctrine it
disproved ("shader effects render nothing on this port's software path") came
from an offscreen observation, and offscreen forces software by definition.
cc
Full-parity port of the Slint QConnect integration per the contract at
qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three
adversarial review rounds on the spec, §12 trap sign-off PASS on both
halves, RFB-verified incl. a real cloud connect, live session
owner-tested). The shared crates are consumed verbatim — zero backend
changes.
- adapter quartet: qconnect_transport_qt (credentials, identity on the
shared qconnect_settings.db), qconnect_engine_qt (start_track_stream
fallback chain + feeder helpers + CMAF cursor sync),
qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect
recipe, controller routing, echo guards, offline parking B12, startup
auto-connect)
- QbzQConnect bridge singleton + on_session_entered wiring; one shared
flyout for both bars (tooltip on the large bar only), Developer >
Connect diagnostics modal, device-row glyphs, settings rows live
- poll loop: peer-reflection branch before cast, ~2 s renderer reports,
sync-on-add on every Qobuz-castable add path
- routing: cast → QConnect → local (mute QConnect-first), play arms
after the set_queue funnel, reorder cloud arm with no local fallback,
volume/mute/seek remote-first
- touchpoints: lyrics remote anchor, scrobble guarded / Discord
unguarded, cast suspend/resume, volume-lock lifted when remote
km
Ports the whole playlist-organisation domain, which had no counterpart in
this frontend, plus the Library work the owner asked not to defer.
Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the
already-ported folders_qt repository, which until now had zero consumers.
Grid, list and folder-tree modes; sort, filter and search; folder cards and
chips; drag reorder and up/down arrows. Reached from the sidebar's "..."
menu, whose New folder / Manage playlists / Import rows had been rendering
dimmed and inert behind a comment saying the seam did not exist.
Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset
or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls
its LOCAL members' folder_id explicitly, because the app keeps the
foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for
them.
QbzPlaylistEdit. One shared rename/description/delete editor replacing the
inline popup in PlaylistView. Three corrections the old path had wrong: the
description is carried through a rename instead of being written as NULL,
a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and
silently no-ops on a playlist you do not own), and the post-delete back
navigation is conditional on actually being on that playlist's page, so
deleting from the manager no longer navigates you off it.
Importer. Spotify / Apple Music / Tidal / Deezer, over the shared
qbz-playlist-import crate.
Picker local mode. The reference's four-case matrix — target local or Qobuz,
payload local refs or Qobuz ids. Local playlists list unconditionally and
Qobuz ones only when online. Local refs onto a Qobuz playlist write the
library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the
0-based enumerate the reference used before collided slots and lost rows.
The carried payload can no longer be parsed as a catalog id when it is a
library row id. Unblocks the local bulk add-to-playlist.
Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab
persistence and multi-select. Preferences land in the same
<data-dir>/qbz/favorites_ui.json the shipping build writes, so switching
between the two frontends keeps them. LibraryView is split into
views/library/ on the way past 1800 lines.
Local library. Group-by now persists; playback queues the order on screen
rather than the SQL page order; switching album-identity mode invalidates
the Artists tab; artist matching folds diacritics and compares normalized
credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being
two artists.
Offline. activate_offline never persisted a last-user marker, so every
per-user path resolved to None and all local reads came back empty; a
session that started offline saw no local playlists at all. The sidebar's
load-once latch was function-local and therefore unreachable from the logout
reset, so a second login never rebuilt the tree. Both are session-scoped
now, and the sidebar Refresh row runs the local-safe verb instead of being
dimmed with no way to recover.
Also fixes, found while verifying rather than reported: artwork derivatives
were written straight to their final path, so a QML Image resolving one
mid-encode opened a truncated png and Qt cached that failure against the URL
for the rest of the session; Library's track group separators referenced a
Component declared in another delegate's scope and could never render; the
genre filter was drawn on Tracks and Albums but applied only on All; an
All-feed row called a player method that does not exist; and the sidebar's
drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot.
17 new msgids across all 8 locales.
cc
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
Three identical faults in dmesg over three days, all
`segfault at 8 ... libQt6QmlModels.so.6.11.1[420c5]`. `nm -DC` puts that
offset in `QQmlDelegateModel::model()`; the QVariant return means sret,
so %rsi is `this` and address 8 is a null pointer plus the member offset.
`QQuickItemView::model()` reads its d-pointer, checks a QPointer guard and
then calls straight through without testing the value.
The re-entry is `onContentYChanged`, not `onModelChanged` as first
assumed: `setModel()` emits it from inside the window where the old
delegate model is already gone. Switching to Labels re-derives the model
AND clamps contentY to a shorter list, which is the emit -- hence the
intermittency, since at contentY 0 nothing is emitted and nothing
crashes. Reproduced in plain qml6 with no app and no login, and bisected:
readback in onContentYChanged dies, in onModelChanged survives 401 swaps,
cached array survives everything.
The fix is to stop asking the view for its own model. A tree-wide audit
found `.model` is the only member that is unsafe this way in 6.11.1 --
count, contentHeight, indexAt and itemAtIndex all null-check -- and the
three LocalLibrary tabs already cached their arrays, which is why this
stayed a Library-only bug.
Also: `clip: true` is a RECTANGULAR scissor in Qt Quick and does not
follow `radius`, so the album/artist header band painted over the content
pane's rounded corners. Slint clips to the rounded shape on the same
element, which is why the identical structure is correct there. The mask
sits OUTSIDE the frame rather than at a high z, because ADR-009 pins
in-pane modals at z >= 3000 with no ceiling and a z fix would be a
standing arms race.
cc