- Checkboxes are now whole-row clickable (new CheckRow primitive, HTML <label>
behavior): clicking the text toggles it, not just the box. Applied to the
welcome accept + the three Configure progress checks.
- "Use my current queue" guards an empty queue: it checks get_all_queue_tracks
and shows "Your queue is empty — add some tracks first, or press Play test."
instead of sitting on a dead read-back.
- Welcome step gains a "What to test with" notice recommending one track of each
bit-depth/sample-rate (16/44.1, 24/44.1, 24/96, 24/192) so users who bring
their own queue exercise every rate.
cc
"Use my own music" -> "Use my current queue" (the button resumes the existing
queue, it doesn't open a picker). Adds explicit Prev/Next icon buttons that call
the proven NowPlayingState.previous()/next() transport, so they step through
whatever is playing — the 4 curated test tracks or the user's own queue.
cc
From owner feedback:
- The body Flickable now has a visible ListScrollbar (wrapped in a Rectangle so
it can overlay) — the review step with multiple DAC accordions can be tall.
- Configure step gains Tauri-style progress checkboxes (backup / config files /
restart) so the user can track what they ran; Next is gated on all three.
- The trigger moves to the end of the BIT-PERFECT group (the wizard's whole
point is bit-perfect) and now wears the FLACDalf (gandalf) icon, which also
replaces the modal header icon. gandalf.svg copied into the Slint icon assets.
cc
The review step generates the copy-paste config per selected DAC (read-only —
QBZ never writes these files, Decision 2): an honest info banner, a backup
CommandBlock first, then one collapsible accordion per DAC (Decision 5 multi-DAC)
holding the three snippets ported 1:1 from the Tauri wizard — pipewire
allowed-rates, per-app bit-perfect client rules, and the WirePlumber rule that
pins node.name + rates + disables resample/channelmix — and finally an
init-aware restart CommandBlock. Rates are re-probed at generation time
(query_dac_capabilities); the filename slug is the description (fixes the old
dacShortName regex that only kept two underscore tokens). The done step lists
the config paths.
Controller: checked_dacs -> gen_configs_blocking (off-thread re-probe + template
build via line-join, no format!-brace escaping) -> apply_configs; toggle_config
flips an accordion. gen-configs fires on the select-dacs -> review transition.
2 unit tests (slugify, wireplumber pins node+rates). DacConfigRow state +
gen-configs/toggle-config actions.
cc
Fixes from owner testing of the Slice 9 playback test:
- No audio: removed the incomplete reinit_device(node) that routed output to the
selected DAC (the proper switch is set_output_device -> reload_settings ->
reinit; doing it half-way broke playback). The test now plays through the
current output; route QBZ to the DAC under test in Settings > Audio.
- Bit-perfect read-back: dropped the pw-top panel (batch snapshot never reflected
the live nodes) and enriched the N6 read-back to show the DAC's real
/proc/asound hw_params — rate + ALSA container format (S32_LE etc.) + channels.
That container format is the bit-perfect proof. Probe scans for the actively
playing card, so it's DAC-agnostic (answers the multi-DAC case: one output at
a time).
- Track jump: clicking a test row now re-sets the queue at that index via the
working play_tracks path (play_index alone only moved the pointer). Resolved
tracks are stashed for this.
- "Use my own music" button: starts the read-back + resumes the user's own queue
for those who don't want the curated tracks.
- Row hover no longer changes the row height (fixed-size glyph slot; the ♪->▶
swap can't reflow what's below).
cc
player.get_sample_rate/get_bit_depth don't exist on Arc<Player>; the accessors
live on player.state (as the QConnect engine already does). Fixes the Slice 9
build.
cc
Replaces the impossible "watch pw-top" verify step with an in-wizard test. The
overlay modal doesn't grab the window, so playback runs underneath while the
test plays.
start-test resolves the 4 owner-curated tracks by id-hint first, then by
"artist title" search if the id 404s (a pulled license — never raw-id-only),
routes QBZ output to the selected DAC (reinit_device), and plays them via the
existing play_tracks path. A 1.5s Slint Timer drives poll-test while playing:
it reads the rate QBZ REQUESTED (player.get_sample_rate — honest) and the DAC's
REAL negotiated rate (N6 / negotiated_stream_rate over /proc/asound), showing a
green check only when they actually match. Read-only; protected audio path
untouched.
State: reuses the test-* fields from Slice 5; adds a poll-test callback and a
TEST_NODE thread_local (no extra model). 3 unit tests already cover the pure
seed-matching/formatting helpers.
cc
Replaces the manual node.name paste with auto-enumeration. Controller
detect_blocking() enumerates via the Slice-0 pw-dump path, projects each sink to
a DacCandidate (looks_like_dac = is_hardware && bus in {usb,pci}), and probes
rates (Slice-2 query_dac_capabilities) for the likely DACs; apply_candidates
pre-selects them and flips has-enumeration off when empty. toggle_dac flips a
VecModel row + recomputes the Next gate; validate_manual is a 1:1 port of the
Tauri validateNodeName/detectDacType (+3 unit tests).
UI step 2: circular SelectionCheckbox rows with a bordered bus label (no pill),
a default star and the probed rate list; a "Can't see your DAC?" disclosure
reveals the two wpctl CommandBlocks + a validated node.name input (shown
unconditionally when nothing enumerated). Next gates on any-dac-selected ||
manual-valid; enumeration fires on the check->dacs transition. Read-only.
cc
Wires the check step on top of the Slice-0/2/6 backend: a health verdict
(success/warning WarningBanner — new `success` variant), a per-missing-piece
CommandBlock with copy, and TWO overridable dropdowns — distro (package
manager) and init system (service commands), which are orthogonal so a Gentoo
box on systemd vs OpenRC gets the right restart command.
New `src/dac_wizard.rs` controller: open() runs the probe off-thread
(spawn_blocking → upgrade_in_event_loop), caches it, and recompute() rebuilds
remediations from the cached probe + the current distro/init selections.
Init-aware restart_cmd (systemd/OpenRC/runit/s6/dinit). NixOS special-cased to
a declarative configuration.nix block.
Sandbox honesty: in Flatpak/Snap the host probes are blind, so the check step
shows an info banner + reference setup commands (full stack incl. pipewire-alsa
+ restart) for the chosen distro/init instead of a false health verdict.
main.rs wires on_open/on_set_distro/on_set_init/on_copy_command; the Audio
settings trigger calls DacWizardActions.open().
cc
InitSystem gains ALL/index/label so the wizard can offer a manual init override
(for sandboxes and for generating commands for another machine).
Sandbox awareness: detect_sandbox() (/.flatpak-info, $SNAP). Inside a sandbox
/etc/os-release is the RUNTIME's (Flatpak freedesktop-sdk) or the snap base's,
not the user's distro, and /run + /proc/1 reflect the sandbox — so detect_distro
now reads the host-exposed os-release first (Flatpak /run/host/os-release, Snap
/var/lib/snapd/hostfs/etc/os-release, then /etc), and detect_init returns
Unknown in a sandbox (the host init is invisible; the wizard's override decides).
cc
Decouple service commands from the distro: the init system is detected at
RUNTIME (detect_init: /run/systemd/system, /run/openrc, /run/runit, /run/s6-rc,
/run/dinitctl, then /proc/1/comm), because it is orthogonal to the distro —
Gentoo runs OpenRC *or* systemd, Debian runs systemd or sysVinit/runit (antiX).
So install commands key off the distro (package manager) and service/restart
commands key off the detected init.
Adds the systemd-free derivatives Antix (Debian) and Artix (Arch) — matched
BEFORE their parent family since their ID_LIKE would otherwise misclassify them
and emit systemd commands — plus NixOS (declarative). InitSystem enum +
parse_init_from_comm. 6 health tests (incl. systemd-free-beats-parent + init
classification).
cc
`qbz-audio::health` for the wizard's check step: audio_stack_health() probes
wireplumber (systemctl OR `pgrep` fallback so non-systemd inits like
Gentoo/OpenRC and Void/runit don't read as down), pw-dump, the ALSA->PipeWire
bridge PCM (`aplay -L` `^pipewire$` — needed for CPAL PLAYBACK, not just
enumeration), pactl, and any-sink presence. detect_distro() classifies
/etc/os-release (ID then ID_LIKE; strips single AND double quotes — Gentoo
single-quotes its value). Distro enum carries the dropdown order + labels.
Pure parse_distro + 4 unit tests (Ubuntu, Pop/EndeavourOS via ID_LIKE,
fedora/arch/suse/gentoo+single-quote/void/other, index round-trip). Read-only.
cc
DacWizardState/DacWizardActions/DacCandidateRow in state.slint (re-exported in
app.slint). DacWizardModal: scrim + 620px card + dot/label stepper rail +
Flickable body with if-step blocks + footer Back/Close/primary. Navigation is
inline state mutation (no Rust yet); the Actions callbacks (copy-command/
run-detect/query-caps/start-test) are declared for Slices 6-9. welcome step is
real (disclaimer + accept checkbox gating Start); steps 1-5 are titled
placeholders. Mounted in AppShell; "Open Wizard" 160x34 trigger added to Audio
settings, gated on PipeWire.
cc
CommandBlock: surface-elevated copy-paste card (Radius.sm, no pills) with an
optional caption, a monospace wrapping command body and a copy button that
flips to a green check for 2s (Timer). Read-only — the copy() callback is wired
to the clipboard by the consumer; nothing is executed.
WarningBanner: info/warning/error variants (tone drives icon + 10%/30% alpha
bg/border, matching the existing inline banners). Adds lucide copy.svg.
cc
Extract the DAC capability assembly out of the Tauri-only
`v2_query_dac_capabilities` into `qbz-audio::dac_capabilities` so the Slint
wizard can probe a DAC's real supported sample rates + pretty description
without a Tauri command. The detection itself already lived in this crate
(`PipeWireBackend::get_sink_supported_rates` -> /proc/asound, with the
`get_device_supported_rates` ALSA-direct fallback); this just assembles the DTO
frontend-agnostically and benefits from the Slice-0 pw-dump-robust enumeration
for the description.
Formats stay nominal (S16/S24/F32) and are documented as NOT probed — honest
port of the prior behavior, not a fake measurement. Pure `assemble` helper +
3 unit tests. Read-only.
cc
The playback test needs the rate the DAC is ACTUALLY clocked at, not the rate
QBZ requested (`player.state.get_sample_rate()` only proves "QBZ asked for X").
Ground truth on Linux is `/proc/asound/cardN/pcm*p/sub0/hw_params` (`rate:` line
while a stream is open, `closed` when idle). The card number is resolved from
the PipeWire node.name via `pw-dump` (robust — no pactl/pipewire-pulse).
New `qbz-audio::dac_probe`: `NegotiatedRate { sample_rate, format, channels }`,
pure `parse_hw_params` + `parse_alsa_card_for_node` helpers, and the composed
`negotiated_stream_rate(node_name)`. Read-only — never opens/reconfigures a
stream, protected bit-perfect path untouched. 4 unit tests (active 24/192
fixture, closed/empty/no-rate, card resolution from int and string props).
cc
Enumerate PipeWire sinks via `pw-dump` (the daemon's native socket) as the
primary path, falling back to the existing `pactl` parser only when pw-dump is
absent or empty. pw-dump needs only `pipewire-bin` — not `pipewire-pulse` (the
Pulse server pactl talks to) nor `pipewire-alsa` (the bridge PCM CPAL relies
on), which are commonly missing on PipeWire-only Ubuntu and produced the empty
device list. As a bonus it yields the exact `alsa_output.*` node.name that the
HiFi wizard otherwise asks the user to paste by hand.
Discovery code only: `create_output_stream` and the protected sample-rate
passthrough path are untouched. max_sample_rate stays None from pw-dump because
its EnumFormat reports the currently negotiated rate, not the device maximum —
the /proc/asound capability probe remains the honest source.
Pure `parse_pw_dump_sinks` helper + 2 unit tests (fixture: USB DAC + PCI card +
filtered-out capture source; garbage/empty JSON).
cc
Album and local-album track lists render a "Disc N" header above the
first track of each disc when the album spans more than one disc
(media_number-driven; single-disc albums render flat), matching Tauri.
Each header carries a context menu (new — Tauri has none) with Play all /
Shuffle / Add to queue / Play next scoped to that disc's tracks only; the
whole-album header buttons are unchanged.
The menu lives in a DiscHeaderMenu wrapper because Slint rejects a
PopupWindow placed directly under a for/if delegate — wrapping it makes
the popup a non-root child (same pattern as TrackRow + TrackContextMenu).
Every TrackItem constructor gains disc-header-number: 0; artist
top-tracks (a flat cross-album list) default the disc to 1.
cc
Single-cover Discover playlist cards now letterbox the cover with
image-fit: contain over its dominant colour (computed host-side from the
decoded cover), matching Tauri, and show the first tag as an accent
category subtag. Add a multi-select category filter dropdown (sm toolbar
variant, no pills per ADR-008) that re-filters the cached playlists row
client-side; an empty selection shows everything. Raise the playlists
cap from 18 to 100 so the filter operates on a full set.
cc
Bring the Slint mix generation to Tauri parity: build a track_to_analysed
payload from up to 9 spread-sampled seeds (pick_spread -> get_track ->
{track_id,artist_id,genre_id,label_id}, drop artist_id==0) for the PRIMARY
/dynamic/suggest algorithm, limit = max(1, 50 - analysed), and fall back to
the empty-analysis call when the primary returns nothing. The listened-seed
build is factored into mix_listened_seed_ids (recents + favorites, ~120,
Qobuz-only) as a seam the reco-store swap (b3) replaces. Logs seed/analysed/
result counts so an empty mix is diagnosable.
cc
Slint reserves 'row' as a GridLayout attached property, so the configurator
modal's 'in property <ConfigRow> row;' failed to compile ("Cannot override
property 'row'"). Rename it to 'entry' — the declaration, the seven
root.row.* accesses, and the for-binding pass-through.
cc
Cleanroom port of the Tauri reco-store into qbz-app::settings::reco_store
(frontend-agnostic, no tauri::State, ADR-006) so the Slint frontend can seed
DailyQ/WeeklyQ, the WeeklyQ 7-day window, and top-genres. Opens the SAME
per-user reco/events.db that Tauri uses (cross-frontend history share); ports
the reco_events schema + indexes, log_play/favorite_event, get_recent and
get_favorite track ids, a windowed get_recent_track_ids_since, get_top_genres,
get_home_seeds, and the train() scorer verbatim. 6 headless tests green.
Tauri's own reco_store is left as-is (rewiring it is a deferred cleanup).
cc
Tauri's DailyQ/WeeklyQ /dynamic/suggest seeds a track_to_analysed payload
(~9 resolved {track_id,artist_id,genre_id,label_id} tuples) for the PRIMARY
mix algorithm; the empty-analysis call is only its fallback. The Slint port
only ever sent the empty-analysis path. Add a TrackToAnalyse model
(qbz-models) and a get_dynamic_suggest_full method on the qobuz client and
qbz-core that carries the payload; the existing get_dynamic_suggest now
delegates with an empty analysis (behaviour unchanged). Plumbing only — the
mix.rs build of the analysis tuples + the two-call fallback lands next (a2).
cc
DailyQ/WeeklyQ build their /dynamic/suggest seed from recently-played track
ids. Recently-played now includes local and Plex tracks whose ids are not
Qobuz catalog ids — numeric Plex rating keys even passed the u64 parse, so
they reached Qobuz and the mix request failed. Filter the seed to Qobuz (and
legacy pre-source) tracks before parsing, and skip the request when no Qobuz
seeds remain. Local tracks stay visible in the Recent rows.
cc
The section list used vertical-stretch inside a panel sized to
preferred-height; a stretch child there collapses to ~0 (a Flickable's
intrinsic preferred-height is ~0 — the documented Slint trap), so the list
was near-invisible. Size it to its content capped at the window minus the
modal chrome; the Flickable scrolls when the rows exceed it.
cc
The configurator offers Qobuzissimes on the Home tab (in DEFAULT_PREFS.home,
off by default), but Slice 2d had removed it from the Home cache, so enabling
it rendered nothing (descriptors_for found no backing data and dropped it).
Visibility is now governed by the prefs (off by default), so keep the data in
the Home cache pool; enabling Qobuzissimes on Home now renders.
cc
The prefs-driven Home skeleton gated on home-sections/editor-sections being
empty, but the descriptor list already carries the non-album rows at seed
time, so the gate never fired and the user saw empty-state placeholders
during the initial fetch instead of the skeleton. Gate the skeleton on
HomeState.loading alone, and suppress the Recently-Played placeholders while
loading, so only the skeleton shows during the fetch (1:1 Tauri).
cc
Add the "Customize" modal that drives the Slice 5 prefs: reorder + show/hide the
active tab's Discover sections, live-persisted (no Save button).
- DiscoverConfigModal.slint: scrim + centered panel (CreatePlaylistModal idiom),
title + close-X, an amber triangle-alert perf-warning banner, an "{} of {}
enabled" count, a scrollable section list, and a Reset-to-defaults footer
pinned left. ConfigRowView = [QbzCheckbox] [label] [up] [down]: the row tap and
the ADR-008 checkbox both fire toggle-section; the 28px ghost ReorderButtons
fire move-section and dim at the list boundary (index 0 / last). Section labels
come from DiscoverState.config-rows (resolved in Rust); chrome strings are @tr
literals.
- HomeView.slint: bind the previously-dead gear to
DiscoverActions.open-configurator() (opens for the active tab).
- AppShell.slint: mount DiscoverConfigModal after the other modals so it sits on
top of them (ADR-009 z-stack by declaration order).
cc
Replace the fixed Discover layouts with the prefs-driven descriptor repeaters
that read DiscoverState's ordered ENABLED section lists, so the configurator's
visibility + reorder choices drive what renders.
- HomeView.slint: drop `for section in HomeState.sections` + the 5 hardcoded
typed blocks; render one repeater over the active tab's descriptor list
(editor-sections when active-tab == "editorPicks", else home-sections). The
single-root delegate dispatches on desc.id: generic album carousels bind the
embedded desc.section; qobuzPlaylists / continueListening / recentlyPlayedAlbums
/ mostStreamed(slim) bind HomeState fields and keep their length gates +
placeholders. `if forYou: ForYouView` stays a static sibling.
- ForYouView.slint: replace the fixed if-sequence with a descriptor repeater
over foryou-sections; each arm reads the typed ForYouState field by id and
keeps its own length gate (qobuzMixes excepted — always rendered). The
skeleton stays a static sibling. essentialsByGenre has no arm (Slice-2c-blocked;
Rust drops it from the list).
- home.rs: select_tab writes DiscoverState.active-tab (single source of truth,
alongside the kept HomeState.active-tab pill bindings) and re-renders the
active tab from the cache via the descriptor lists; apply_home pushes the
descriptor lists once the cache is populated.
- main.rs: the initial discover load fires album-section artwork via the
descriptor-targeted jobs returned by select_tab (DiscoverSectionAlbum), spawned
on the UI thread; slim-grid / recent-album / playlist covers still target the
HomeState fields the fixed arms render.
Build-time verifications to confirm (documented in the .slint comments): the
for-over-ternary-model form compiles, and an empty delegate does not double the
outer 40px spacing (Rust already drops data-empty album ids — policy b).
cc
Add the Slice 5 foundation for the Discover section configurator: a single
DiscoverState/DiscoverActions global pair owning both the prefs-driven render
descriptor lists and the modal payload, plus a thin frontend controller over
the headless qbz_app discover_prefs store.
- state.slint: SectionDescriptor + ConfigRow structs and the DiscoverState /
DiscoverActions globals (active-tab lives here; render loop + modal read it).
- app.slint: re-export the new globals/structs so the Rust layer can bind them.
- discover_prefs.rs: per-user store lifecycle (init_for_user/teardown mirror
fav_cache), in-memory authoritative prefs, descriptor + config-row push
helpers, id->kind/label maps, and toggle/move/reset/open/close handlers
(mutate -> persist -> re-push -> re-render, re-firing artwork for the active
Home/Editor tab). Labels resolve in Rust (English today) since Slint @tr needs
a literal key and the gettext pipeline is unwired.
- home.rs: add the SectionData.id key; build the Home/Editor ordered descriptor
lists from the cached section data (album ids embed their DiscoverSection,
fixed ids carry an empty section); rerender_active_tab + descriptor artwork
jobs. Empty album sections are dropped (policy b); placeholder-bearing Home
sections are kept and gated in the view.
- artwork.rs: DiscoverSectionAlbum target so covers paint on the descriptor
list (DiscoverState.home-sections / editor-sections) rather than HomeState.
- main.rs/auth.rs: mod + seed at both shell entries + per-user init/teardown at
every session-activation point + the 5 DiscoverActions callback registrations.
Descriptor lists are pushed but not yet rendered (the views still draw the old
way); no behavior change until the render loop lands.
cc
The new ("playlist","play") media-action arm gave these cards a body-click
action for the first time. Without body-opens they defaulted to play, so a
playlist card body-click played instead of opening (and before the arm
existed, did nothing at all). Set body-opens:true on the Search carousel,
Search grid, and Label carousel so body-click opens the playlist while the
overlay button plays — matching the album-card convention and Tauri.
cc
The Spotlight hero portrait was 96px; enlarge to 140px (border-radius
70px to stay circular) for parity with Tauri. Layout-safe: the inner
Image is 100% image-fit:cover, the fallback glyph re-centers, the hero
HorizontalLayout has no fixed height so it grows, and the page Flickable
absorbs the vertical growth.
cc
The More From Your Library / similarAlbums row was hardcoded "More From
Your Library"; Tauri titles it "Similar to {seed}" where seed is the album
the suggestions are seeded from (discovery.similarTo).
Thread a seed_title through apply_more_from_library: the common case passes
the most-recent album title (recents_seed_title), the no-recent-history
fallback passes the first favorite album title. Empty seed falls back to
"More From Your Library" so the row is never titleless. Title stays a Rust
data string (rendered raw at Carousel.slint), matching the other For You
section titles.
cc
Qobuzissimes was pushed into the Home sections vec unconditionally, so
it always rendered on the Home tab — but Tauri's home default has it OFF.
Editor's Picks keeps it (push_section_ref, correct). Remove the Home push;
borrow-safe (containers.qobuzissims is now consumed only by the editor
push_section_ref).
cc
Add the missing For You "Favorite Albums" carousel (Tauri's
favoriteAlbums), the user's favorited albums capped at 18 in favorite
order, self-hiding when empty. Reuses the album fav-fetch already done in
albums_branch — no new endpoint.
- state.slint: ForYouState.favorite-albums: DiscoverSection.
- foryou.rs: build_favorite_albums (cap 18, unfiltered) + apply_favorite_albums
(mirrors apply_rediscover); wired into albums_branch right after fav_albums
resolves, painting in the same layer as rediscover/radio.
- artwork.rs: ForYouFavoriteAlbum { index } variant + apply arm.
- ForYouView.slint: mount the Carousel after Your Top Artists, before More
From Your Library, gated on favorite-albums.albums.length > 0.
Titled "Favorite Albums" (not Tauri's literal "More From Your Library")
to avoid the label collision with similarAlbums.
cc