Port #470's UI behavior: add an "Auto" entry at the top of the backend
dropdown that, on select, resolves to the best available backend (PipeWire
if present, else System) and persists that concrete value. The rebuilt
snapshot then moves the dropdown onto the resolved backend, so backend_type
is never left null/Auto. Concrete backends keep mapping by their shifted
index; System wiring comes from available_backends() (merged backend half).
cc
The catch-up merge staged the pre-release lock (--theirs) before cargo
regenerated it, so the committed lock dropped qbz-slint's GUI dependency
tree (slint / accesskit / winit / skia / ab_glyph, ...). Commit the
regenerated lock so the checked-in slint-mvp lock matches the workspace.
cc
qbzd was a premature daemon experiment that never shipped (gated off
behind QBZD_ENABLED, no user access). Its ratatui 0.29 dependency pinned
unicode-width =0.2.0, which conflicts with slint's annotate-snippets in a
shared workspace. Remove it entirely; it will be reimplemented from
scratch later on the clean, UI-agnostic shell.
- Delete crates/qbzd daemon crate + workspace member (drops ratatui,
crossterm and the other qbzd-only deps from the lock).
- Delete src-tauri qbzd_discovery (mDNS) + its commands/state wiring in
lib.rs; drop the direct mdns-sd dep (Chromecast keeps its own via
qbz-cast).
- Delete remoteApi/remoteEvents/playbackTargetStore; neutralize
commandRouter to local-only dispatch (isRemote/skipIfRemote are now
always-false no-ops so the ~100 guard call-sites keep compiling).
- Strip qbzd branches from playbackService and the remote banner from
NowPlayingBar; excise qbzd from CastPicker (Chromecast/DLNA kept).
- Remove qbzd i18n keys (5 locales), README daemon node, gitignore note.
cargo check (crates + src-tauri) and svelte-check all green.
cc
OOTB playback froze (seekbar advancing, no audio) on hosts where the
configured backend was not usable — e.g. PipeWire as the Linux default but
`pactl` absent (minimal Debian/Ubuntu). "Auto" (a null backend_type) fell
through to a legacy CPAL path that forced the track rate onto the shared
default device, producing a suspended/silent stream and leaving a
process-wide stuck audio handle that survived Reset.
- Add a cross-platform "System" backend (CpalDefaultBackend now on Linux
too): opens the OS default output at its negotiated rate with rodio
resampling and an explicit ~100ms CPAL buffer (MixerDeviceSink has zero
internal buffering, so the bare default underran and stayed suspended).
Shared, no exclusivity, no `pactl` — mixes with other apps like any
normal player.
- Make System the OOTB default and what Reset lands on; seed it on first
run. Drop the per-startup #375 NULL->PipeWire backfill migration.
- Resolve a null backend_type to System in the player so the legacy CPAL
path is never reached (covers legacy installs and headless callers).
- "Auto" in Settings is now a resolve-and-set action: it picks the best
available backend (PipeWire if available, else System), stores that
concrete value and updates the dropdown + device picker, so backend_type
is never null.
- Explicit PipeWire (DAC passthrough) and ALSA Direct (exclusive) configs
are untouched and remain bit-perfect.
cc
cpal 0.17.3 requires alsa 0.11, which in turn pulls alsa-sys 0.4. The
`links = "alsa"` constraint permits only one alsa-sys in the dependency
graph, so qbz-audio's direct alsa stack is raised in lockstep:
alsa 0.10 -> 0.11 and alsa-sys 0.3 -> 0.4. Both workspace lockfiles
(crates/ and src-tauri/) are updated to cpal 0.17.3 / alsa 0.11.0 /
alsa-sys 0.4.0.
No source changes required: alsa_direct.rs uses only the stable
high-level alsa API (pcm, HwParams, Format, Direction, ValueOr, mixer),
and the snd_lib_error_set_handler FFI in alsa_error_handler.rs is
unchanged in alsa-sys 0.4. Both workspaces pass cargo check.
cc
The stream-recreate decision only forced a rebuild on a decoded-format change
for bit-perfect backends (DAC passthrough, ALSA Direct, CoreAudio exclusive).
The default/PipeWire path relied on Stop dropping the stream to get a fresh one
at the new rate; once that drop was deferred to avoid a track-change click
(e93fcaec), the PipeWire node stayed locked to the first track's rate for all
subsequent tracks — a regression vs 1.2.10. Make any decoded-format change
rebuild the stream on every backend; same-rate tracks still reuse it, so the
click fix is preserved.
Verified on hardware via pw-top: PipeWire without passthrough now switches rate
per track; PipeWire with passthrough and ALSA Direct unchanged (no regression).
cc
is_pipewire_available() shelled out to `pactl info`, which lives in
pulseaudio-utils and is absent on PipeWire-only systems — hiding the PipeWire
backend even though PipeWire was running. Detect via the
$XDG_RUNTIME_DIR/pipewire-0 socket first (no extra package required), with
pw-cli and pactl as fallbacks.
cc
A single transient 5xx / timeout / reset on the next track's file/url or a CMAF
segment used to be terminal and skipped the track. Add a retry wrapper (3
attempts, exponential backoff + jitter) around get_file_url, the init segment,
and per-segment fetches. Retry only transient errors (network/timeout, 5xx, 429
honoring Retry-After); a real 404 is classified as TrackUnavailable and
propagates immediately to the bounded skip path.
cc
Long sessions died when a single transient blip flipped the app to offline and
the queue then skipped every cached-but-not-downloaded track. Harden the
detector:
- Consecutive-failure hysteresis: declare offline only after 2 failed polls,
not a single blip.
- Replace the HEAD https://1.1.1.1 probe with a concurrent race of cheap
generate_204 endpoints, so a saturated link (long Hi-Res tracks) no longer
times the probe out.
- Positive liveness override: if a segment downloaded within the last 45s we
are online by definition, regardless of the probe.
cc
Recovered from 63ecc324 (authored on the native-port branch, never merged to
pre-release). Backend-only, no Slint. Scales the prefetch cap down on slow
links and during audio underruns, and exposes the per-segment download signal
the offline detector reuses as a positive liveness override (#467).
cc
Polish + consistency:
- Compact playlist header (cover 150px; nav->header spacing
homologated with AlbumView); description truncated to ~2 lines +
"Read more" modal; custom-artwork buttons only on cover hover.
- New Typography.section (18px) token for hero/section titles,
applied across Album / Artist / Mix / Playlist + Home carousels.
- Shorter track rows app-wide (50px row, 36px artwork, -1 fonts);
+3px column-header/divider gap; wider track-number column.
- Reusable ExpandableSearch (the JumpNavBar page-search pattern:
magnifier -> opens left -> X to close) in the playlist action row.
- Custom ListScrollbar (thicker, min thumb height, own gutter) since
Slint's ListView scrollbar can't be styled.
Sort:
- Default / Title / Artist / Album / Duration with asc/desc per
field, plus Custom (manual order persisted in library.db with
up/down reorder). Client-side search + sort combined.
WIP: track drag-and-drop onto sidebar playlists
- DragState/DragActions globals, drag source on the shared TrackRow,
follow-cursor DragGhost, sidebar playlist rows self-detect the drop
via absolute-position, multi-select group drag, add_tracks_to_playlist
on drop, Flickables freeze scroll during a drag.
- KNOWN ISSUE: the ghost is offset from the cursor (the row
absolute-position + pointer mapping is off, esp. inside Flickables).
Diagnostic logging is left in (TrackRow debug() + a start log) to
pin down the wrong term before the generic fix.
cc
Large playlists (2000+ tracks) were sluggish. Addressed the root
causes; this strategy is reusable for the library / mixtapes / tracks
views.
- Virtualization: the track list is now a ListView (only visible
rows are instantiated) instead of a Flickable with a for over all
rows. The header + column header are pinned above it.
- Smaller images: track rows use ImageSet::smallest() (new) instead
of best() — they are 40px thumbnails, so best() was downloading
mega/large covers.
- Per-target decode size: row thumbnails decode to 96px instead of
the 264px card size, so a 2000-row model holds ~75MB of pixels
rather than ~550MB.
- Track-number column widened to fit 4-digit positions.
- Back-to-top button: appears once scrolled past one viewport
(bottom-right), scrolls the list home. A shared rule for big lists.
- Custom-artwork buttons only show on cover hover.
cc
Local, Tauri-shared playlist folders (library.db) now organize the
sidebar:
- New folders.rs controller (create / rename / delete / move via
qbz-library); sidebar.rs reworked to a flattened entries model
(folder headers + grouped playlists + root playlists) with a
session expand cache, rebuilt without a refetch on expand/move.
- Sidebar renders folders (expandable, with playlist counts) and
playlists (indented inside folders); a hover "..." opens a context
popup: Move to folder (root + each folder) for playlists, Delete
folder for folders.
- Header gains a New-folder button + a CreateFolderModal; folders
reload after create/delete; moves are optimistic + persisted.
Adds folder-open / folder-plus icons.
cc
Wires qbz-library into the Slint app (new library_db module) so the
local, Tauri-shared organization data is read/written from the same
per-user library.db (<data_dir>/qbz/users/<id>/library.db). The DB is
opened per-operation on a blocking thread (rusqlite Connection is
!Send).
- Header collage: the playlist cover is now a 4-up collage of the
first tracks' covers (Tauri parity); falls back to the server
image, then the first track, then a placeholder.
- Custom artwork: owner-only buttons on the cover set/clear a custom
image. Set copies the picked file into <cache_dir>/qbz/artwork and
stores the path in playlist_settings.custom_artwork_path (shared
with Tauri); it's loaded directly from disk and overrides the
collage.
- Edit description: the edit modal gains a description field
(prefilled), saved through update_playlist alongside the name.
cc
The playlist detail view gains an edit mode (owner-only), reusing
TrackRow's multi-select-mode:
- A "Select" toggle in the header turns on per-row checkboxes and a
bulk action bar (N selected, Select all, Remove).
- toggle-select is now context-aware (playlist vs artist Popular);
PlaylistState tracks selected-count for the bar.
- Remove calls remove_tracks_from_playlist with the selected ids,
then reloads the playlist and leaves edit mode.
Track reordering is the remaining playlist-editing piece (deferred —
it is the most involved).
cc
Completes playlist CRUD. The detail header shows an edit button only
for the user's own playlists (is-owner = the open id is in the sidebar
list); it opens an EditPlaylistModal:
- Save -> update_playlist (rename); refreshes the detail header name
and the sidebar list.
- Delete -> delete_playlist; closes the modal, navigates back, and
refreshes the sidebar.
Adds the pen-line icon (Lucide) used by the edit button.
cc
- SidebarState/SidebarActions + crate::sidebar: the user's playlists
load into the left-nav list on shell entry; clicking a row opens
the playlist detail view, with the open playlist highlighted
(active-id). Empty state shown when there are none.
- The sidebar "+" opens a CreatePlaylistModal (name field + public
toggle); submit calls create_playlist, refreshes the sidebar, and
navigates to the new playlist.
cc
First piece of the playlist module: a detail view at the AlbumView /
MixView level, reusing the shared TrackRow (show-artwork + show-album +
context menu, so Add to playlist / Go to album / etc. work in it).
- PlaylistState + crate::playlist controller (get_playlist -> header
metadata + TrackItem rows; cached track list backs play-all /
per-track play; artwork jobs resolve row covers + the header cover).
- PlaylistView.slint: cover + "Playlist" label + name + description +
"owner - N tracks - duration" info line + circular actions (Play
all, Shuffle, Favorite) over a column-headed track list.
- ContentView.playlist + NavEntry::Playlist (back/forward history) +
navigate_playlist; the Favorites > Playlists card now opens it.
- media-action: playlist play-all / shuffle / favorite (add/remove
favorite) + per-track play-from-here.
cc
Ports Tauri's behavior where clicking an album card's artist name
navigates to that artist (the card body still opens the album).
- AlbumCardItem gains artist-id; populated in every album-card
constructor (home/search/label/foryou/favorites). Artist-page
release cards leave it empty (their subtitle slot shows the year,
not the artist, so it stays non-clickable).
- AlbumCard wraps the artist name in a TouchArea that emits
media-action("artist", artist-id, "open") and brightens on hover
only when an id is known.
- main.rs handles ("artist","open") -> open the artist page;
FavoritesView (which only forwarded clicked) now forwards the
artist action.
cc
Connects the previously-inert track context-menu items now that the
backends exist:
- Add to library -> add_favorite("track").
- Go to album / Go to artist -> resolve the track (get_track) to its
album / performer and navigate there.
- Create radio -> Qobuz track radio (new get_radio_track core wrapper
over the existing qbz-qobuz endpoint + playback::play_track_radio).
- Share Qobuz link -> https://www.qobuz.com/track/{id} to clipboard
(new arboard dep + share module).
- Share Song.link -> Odesli (api.song.link) pageUrl resolution to
clipboard.
- Add to playlist -> a global picker modal (PlaylistPickerModal +
PlaylistPickerState/Actions + playlist_picker controller) listing
the user's playlists; picking one calls add_tracks_to_playlist.
Add to mixtape / Make available offline / Track info stay unwired
(no backend yet).
cc
Collapses the three near-identical track rows (AlbumView's inline
TrackRow, ArtistPage's inline TrackRow with multi-select, and the
shared SearchTrackRow) into a single primitives/TrackRow driven by
flags, backed by one TrackItem (superset). Foundation for the
upcoming playlist module, which renders the artwork variant heavily.
- New primitives/TrackRow.slint with: show-artwork (cover+number vs
number cell), show-album, show-favorite, show-download,
multi-select-mode (selection checkbox). One media-action wires
play / play-next / queue / favorite / download / toggle-select +
the TrackContextMenu.
- TrackItem is the unified model (superset of the old AlbumTrackItem
+ SearchTrackItem: number, album, selected, artwork all present;
unused fields stay empty per surface).
- Migrated AlbumView (show-artwork:false, show-download:true),
ArtistPage Popular (show-artwork:false, multi-select-mode),
Search, Favorites, Mix (show-artwork:true, +album/+favorite).
Deleted SearchTrackRow.slint and both inline TrackRows.
- Rust: AlbumTrackItem + SearchTrackItem -> TrackItem across
album/artist/search/favorites/mix/main; search's internal row
struct renamed TrackRow -> TrackRowData to free the TrackRow name
for the Slint component. 46 tests pass.
cc
Track row reuse: the mix views had a bespoke MixTrackRow with a dead
favorite/more controls (no context menu), while search / favorites /
artist already share SearchTrackRow (which has the TrackContextMenu).
Consolidated:
- SearchTrackRow gains optional show-album (album column) +
show-favorite (standalone heart) — off by default, so search /
favorites rows are unchanged. SearchTrackItem gains an `album`
field.
- MixView drops MixTrackRow/MixTrackItem and renders SearchTrackRow
with show-album + show-favorite. A row "play" starts the mix from
that track; the context-menu actions (play next / queue / favorite
/ view album / view artist / ...) now bubble through media-action
and work in the mix views too.
For You skeleton: the For You tab showed a bare spinner during its
lazy load; it now shows the same pulsing HomeSkeleton as Home (the
static Qobuz Mixes tiles stay visible above it). Editor's Picks is
served from the shared discover-index cache (Tauri parity) and shows
the skeleton during the initial load via the existing HomeView guard.
cc
The mix detail pages were a thin header + flat track list. Rebuilt to
mirror Tauri's playlist-detail layout at the AlbumView port quality.
- Header: a per-mix gradient artwork (224px, same gradients as the
QobuzMixesRow tiles) + "Qobuz Mixes" label + title + description +
"N tracks • duration" info line + circular action buttons (Play
all, Shuffle, Save-as-playlist, Refresh) — matching AlbumView's
header anatomy.
- Track list with a real column header (#, Title, Album, Duration,
Quality) over rich MixTrackRows: artwork thumbnail + number/play
cell (TrackPlayCell, artwork-on), title + explicit badge, artist,
album column, duration, quality, favorite + more controls.
- MixTrackItem gains an album column; MixState carries track-count +
total-duration for the info line.
- Play all / per-track play (from that track), Shuffle (plays a
random order without mutating the list), and Refresh (re-fetches)
wired through media-action.
Fixes a wiring bug: the tile click sends id=kind/action="open", but
the handler matched on the action and passed "open" as the mix kind —
now it reads the kind from id.
cc
The For You Qobuz Mixes tiles now open functional mix detail pages.
- New MixView: header (mix name + description + Play all) over the
mix's track list (reusing SearchTrackRow). Clicking a track plays
the whole mix from there; Play all starts at the top.
- crate::mix resolves each mix to a track list from available
backend: DailyQ/WeeklyQ -> /dynamic/suggest seeded from the local
play-history track ids; FavQ -> favorite tracks shuffled (xorshift,
no rng dep); TopQ -> tracks aggregated from the user's playlists.
The loaded list is cached so play-all / play-from-track build the
queue without re-fetching.
- Backend: ported /dynamic/suggest into qbz-qobuz
(QobuzClient::get_dynamic_suggest — POST, items parsed leniently
into Vec<Track>) + QbzCore::get_dynamic_suggest.
- ContentView.mix + NavEntry::Mix { kind } so each mix is a history
entry; the tile click records + navigates. New MixTrack artwork
target. playback::play_tracks generalizes the radio queue builder
with a start index (radio reuses it at 0).
Tauri's exact generation (listened-track analysis payloads, playlist
play-stats ranking) is approximated; same surfaces + playback, sourced
from the backend the MVP has.
cc
Ports the Tauri radio_engine into a standalone, frontend-agnostic
crate so any client (Slint, TUI, CLI) can build and play a smart
radio without Tauri.
- New crate qbz-radio (builder + db + engine + tests), copied
verbatim from src-tauri/radio_engine with the only change being
the dependency rewrite: crate::api::{QobuzClient, Track,
TracksContainer} -> qbz_qobuz::QobuzClient + qbz_models. Depends
only on qbz-qobuz + qbz-models + rusqlite + dirs. All four engine
tests pass.
- QbzCore::create_smart_artist_radio builds the session pool + pulls
up to 50 track ids + resolves them to full tracks. The radio DB's
rusqlite Connection is !Send, so the build + pull run on
spawn_blocking threads (the async builder driven via block_on),
mirroring the Tauri command.
- The artist "radio" action (Spotlight RADIO card + artist radio)
now uses the smart builder (playback::play_smart_artist_radio);
album radio (Radio Stations) keeps the Qobuz /radio/album
endpoint, so both radio systems are in use. play_artist_radio
(Qobuz /radio/artist) is retained as the simpler alternative.
cc
Completes the remaining For You sections by porting two Qobuz API
endpoints down into qbz-core and adding the local "forgotten
favorites" approximation.
Backend ports (qbz-models + qbz-qobuz + qbz-core):
- /album/suggest -> AlbumSuggestResponse, QobuzClient::get_album_suggest,
QbzCore::get_album_suggest.
- /radio/artist + /radio/album -> RadioResponse, get_radio_artist /
get_radio_album on the client + core. (Faithful ports of the legacy
src-tauri/api/client.rs methods — same paths, params, shapes.)
For You sections:
- Radio Stations — album-seeded radio tiles (recent + favorite
albums, deduped, capped 12), rendered with RadioCard; click starts
a real Qobuz album radio.
- More From Your Library — /album/suggest seeded from the most recent
played album (else first favorite).
- Rediscover Your Library — favorite albums not in the local
play-history (approximation of Tauri's reco-DB forgotten
favorites).
- Spotlight's RADIO card + the artist "radio" action now create a
real Qobuz artist radio (playback::play_artist_radio /
play_album_radio build a queue from the radio track list and play).
Section order now matches Tauri: Qobuz Mixes, Release Watch, Radio
Stations, Recently Played Tracks/Albums, Your Top Artists, More From
Your Library, Rediscover, Artists to Follow, Spotlight.
Still backend-blocked: the Qobuz mix detail views (DailyQ/WeeklyQ/etc.)
and the smart radio_engine (the Qobuz radio used here is the simpler
/radio/* endpoint, not the local pool builder).
cc
Ports Tauri's QobuzMixesRow as the first For You section — four
220px navigation tiles (DailyQ / WeeklyQ / FavQ / TopQ) with the
1:1 gradients, badge, name, and description. Pure navigation, no
per-tile data. The mix detail views they open don't exist in the
Slint MVP yet, so the click is logged for now.
cc
Ports Tauri's SpotlightLite — one favorite artist highlighted with a
hero and a content row. Backed by get_artist_page.
- Spotlight component: header (title + "Shine a light…" subtitle),
hero (round portrait + ARTIST label + name + play-top-tracks /
open-artist circle buttons), and a draggable Flickable row
mixing a TOP TRACKS card, a RADIO card, and the artist's albums.
- New RadioCard (port of RadioCardLite): a 180px tile with the seed
artwork centered on an elevated panel, a wordmark label over it,
and a title/subtitle below; hover reveals play. (The Tauri
palette-derived panel colour is approximated with a neutral
elevated panel.)
- foryou::load_spotlight rotates among the top-5 favorites by
wall-clock seconds, fetches get_artist_page, and extracts up to 6
albums (album > live > ep-single > compilation, deduped) plus
whether the artist has top tracks. ForYouState gains the
spotlight-* fields; two new artwork targets feed the portrait +
album covers.
- Wiring: ForYouView renders Spotlight after Artists to Follow.
play-top-tracks routes to the existing artist play-top action;
"start radio" has no artist-radio backend yet so it falls back to
playing top tracks (logged).
Remaining For You: Radio Stations section (old card design), Qobuz
Mixes row, and the reco-DB sections (More from library, Rediscover).
cc
Replaces the ad-hoc For You (a single album set crammed into
HomeState.sections) with a dedicated, ordered ForYouView/ForYouState
so the tab can mix album, track, and artist carousels — closer to
Tauri's For You. Lazy-loaded once on first open.
Sections (all reuse existing cards, no new card components):
- Release Watch (album Carousel) — get_release_watch.
- Recently Played Tracks (SlimCarousel) — local play-history.
- Recently Played Albums (album Carousel) — local play-history.
- Your Top Artists (ArtistCarousel) — the user's favorite artists
via get_favorites.
- Artists to Follow (ArtistCarousel) — similar artists seeded from
a few favorites via get_similar_artists, excluding ones already
followed (Follow chip shown).
Plumbing:
- crate::foryou controller (load/apply/artwork). Five ForYou
artwork targets feed the dedicated models.
- select_tab("forYou") clears HomeState.sections (the home-content
`for` renders nothing) and the For You content comes from
ForYouView; HomeView routes the For You tab to ForYouView and
gains an open-artist callback (wired through AppShell).
- ensure_for_you_loaded lazy-loads on tab open (pill, header
dropdown, and back/forward history).
- home.rs no longer builds a For You section set (removed the
redundant release-watch/favorites fetch on every home load).
Still to come (next increments): Qobuz Mixes row, Radio Stations
(old card design), Spotlight hero, and the reco-DB sections (More
from library, Rediscover) — those need backend the MVP lacks or the
mix detail views.
cc
Home / Editor's Picks / For You were plain tab swaps with no history
footprint. Each is now a NavEntry::Discover { tab } pushed on click
(both the in-view pill and the Discover header dropdown), so
back/forward navigates between the three Discover pages. apply_entry
replays a Discover entry by selecting that tab's cached section set
and re-firing artwork.
cc
The popup root Rectangle set width but no height, so as a direct
child of the home overlay it defaulted to the parent's full height —
the panel ballooned vertically. Bind height to the content layout's
preferred-height so it hugs its content.
cc
For You was a single Release Watch row. Adds the personalized album
sections the Slint MVP can source today, matching Tauri's forYou
defaults:
- Release Watch (followed-artist new releases) — kept.
- Favorite Albums — the user's hearted albums via get_favorites.
- Recently Played Albums — from the local play-history store
(carries the stored quality badge).
The remaining Tauri For You sections — Qobuz mixes, similar albums,
rediscover library, essentials by genre, artists to follow, artist
spotlight, continue listening — depend on the reco-DB / home-resolved
backend (v2_reco_*) that the Slint MVP does not have yet, plus an
artist-tile section type for the follow/spotlight rows. Those are the
next increment.
cc
Adds the advanced view to the Discover genre filter and recovers the
child-genre filtering the discovery-v2 home redesign dropped.
Child-genre recovery:
- The old (pre-redesign) home expanded a parent selection to its
descendant genre NAMES and filtered content client-side; the
discovery-v2 redesign passed only the explicitly-selected ids to
the discover index, so selecting a parent stopped covering its
children. Here filter_ids() expands the selection to all loaded
descendant ids and feeds that to get_discover_index; selecting a
genre eager-loads its descendant subtree so the expansion is
complete.
Advanced view:
- Search box + a scrollable 3-level genre tree (parent / child /
grandchild), flattened in Rust into visible GenreTreeRow rows
honoring per-node expansion. Each row: indent by level, expand
arrow (lazy-loads children on first expand), checkbox, name, and
loaded child count. Search returns a flat match list across all
loaded genres.
- Opening the advanced view eager-loads every parent's children so
counts show up front; grandchildren stay lazy until a child is
expanded.
State/wiring:
- GenreTreeRow struct; GenreFilterState gains tree + search-query;
GenreFilterActions gains toggle-expand + search.
- genre_filter STATE moves to LazyLock (HashMap/HashSet aren't
const) and gains children map, expanded set, and search query.
- main.rs wires toggle-expand (lazy child load), search, and the
advanced-open eager child load; toggle now eager-loads
descendants before the re-fetch.
cc
Ports Tauri's Filter-by-genre popup (simple grid view). The selection
is shared across the three Discover tabs — matching Tauri's single
"home" genre context — and re-fetches the discover index with the
chosen genre ids.
UI:
- New GenreFilterPopup: header (icon + title + close), Remember
selection + Advanced view toggles (QbzToggle), a 3-column grid of
parent-genre chips with an accent fill + check circle when
selected, and a Clear filter footer. Anchored top-right under the
button, with a click-away backdrop.
- GenreButton turns accent-filled and shows "N genres" / "1 genre"
when a filter is active (else "Filter by genre"), 1:1 with Tauri.
- Advanced view (search + 3-level tree + counts) is the next
increment; its toggle is present and shows a "coming soon" stub.
State / backend:
- GenreFilterState + GenreFilterActions globals; GenreChip struct.
- crate::genre_filter loads the parent genres (QbzCore::get_genres)
and owns the shared selection, persisted to
<data-dir>/qbz/genre_filter.json when Remember is on (dropped
when off). Stale ids are pruned against the loaded genres.
- load_home now takes genre_ids and threads them into
get_discover_index. The home-load path is extracted into a shared
`reload_home(active_tab)` used by the initial shell load and by
genre toggle/clear; it rebuilds all three section sets, restores
the active tab via select_tab, and re-fires artwork.
cc
Applies the same Flickable drag pattern to SlimCarousel (Recently
Played Tracks, Popular albums). The 4x3 page blocks now live inside a
horizontal Flickable — each block is one viewport width wide, cards
positioned absolutely within it — so the grid drags between pages
with kinetic momentum while slim-card taps still register. prev/next
nudge viewport-x by a page width; enabled state derives from
viewport-x. Two-page (24-item) cap unchanged.
cc
Introduces the horizontal drag concept (to be reused widely) on the
discover album Carousel. Replaces the manual page-slide (a clipped
Rectangle translated by -page*step) with a horizontal Flickable:
the row now drags by touch/pointer with kinetic momentum, and card
taps still register because Flickable distinguishes a flick from a
tap — unlike nested TouchAreas, where the card's own TouchArea would
swallow the drag.
- viewport-height is pinned to the row height so the Flickable
only scrolls horizontally and never fights the home page's
vertical Flickable.
- prev/next buttons nudge viewport-x by one page width (clamped to
[-max-scroll, 0]); enabled state + the edge fades derive from
viewport-x.
- No animate on viewport-x — that would rubber-band the per-frame
drag updates; button jumps are instant in exchange for smooth
dragging.
cc
The play-history store kept only id/title/artist/artwork, so the
Recently Played Albums cards rendered bare (no quality badge) and a
re-play had no stored context to fall back on.
RecentTrack now also persists quality_tier, quality_label and
artist_id; RecentAlbum carries the quality through to its card.
record_recent derives the quality from the live queue track's
bit_depth / sample_rate (same Hi-Res/CD badge format as the discover
cards) and copies its artist_id. home.rs maps the stored quality
onto the Recently Played Albums card so the badge now shows.
cc
Three of the four Discover > Home quick-wins.
Loading skeleton (#1):
- New HomeSkeleton replaces the spinner + "Loading your music…"
text shown while the first fetch is in flight. Grey placeholder
blocks shaped like two real carousels (title bar + a row of
square art with two text lines), gently pulsing via a shared
900ms Timer phase + animate opacity — no more artifact titles
during load.
Recently Played Tracks + Popular albums (#3, #4):
- New SlimCarousel: a paginated 4x3 slim-card grid (12 per page),
capped at two pages, sliding one viewport width per page. The
prev/next controls only appear when there is a second page
(>12 items); 12 or fewer renders as a single static page with
no arrows. Replaces the old non-paginated GridSection that grew
into a tall wall of rows.
- home.rs caps `recent` and `popular` to 24 items (two pages) so
the carousel never has hidden overflow; the Popular/Recent
artwork-job indices are unchanged.
GridSection is retained — the artist ReleaseGrid still uses it.
Note on #2 (Recently Played Albums "spurious AlbumCard"): there is
only one AlbumCard component (discover/AlbumCard.slint, used by 5
call sites incl. this carousel via Carousel) — no duplicate to
delete. The cards look bare because the local play-history store
keeps only id/title/artist/artwork (no quality/year/ribbon), not
because of a different component.
cc
Makes the three-tab Discover pill (Home / Editor's Picks / For You)
functional, plus the matching Discover header-dropdown rows. Mirrors
Tauri's DiscoveryView: all tabs draw from one fetch and swap which
curated section set is shown — no extra round-trips on tab switch.
home.rs:
- load_home now builds three section sets from a single
get_discover_index call (cloning the editorial containers):
* home — the existing balanced set.
* editorPicks — editorial only (New Releases, Qobuzissimes,
Press Accolades, Most Streamed, Ideal Discography, Albums of
the Week), ordered per Tauri's DEFAULT_PREFS.editorPicks.
* forYou — Release Watch (get_release_watch "artists"). The
deeper personalized sections (Qobuz mixes, similar albums,
spotlight) need reco-DB backend the Slint MVP lacks, so this
is the honest first cut.
- apply_home caches all three sets in a thread-local; select_tab
swaps HomeState.sections to the chosen set and returns the
Section-targeted artwork jobs to re-fire. Reuses the existing
Section artwork target — no new targets.
- map_full_album maps release-watch's catalog Album to a card.
Slint:
- HomeState gains active-tab; HomeActions.select-tab wired to the
pill (active state derived from active-tab) and to the Discover
dropdown rows (route "discover-<tab>") via header-menu-navigate.
- The Home-only sections (Recently Played albums/tracks, Popular)
are gated to active-tab == "home" so Editor's Picks / For You
render just their own set.
cc
Completes the five Favorites tabs. Both lazy-load like the others.
Playlists:
- Sourced from QbzCore::get_user_playlists (the getUserFavorites
envelope has no playlists branch; Tauri splits owned-vs-following
locally — the MVP shows the full user playlist list for now).
- FavoritePlaylistItem card: cover (images300 > images150 >
images, first entry), name, "N tracks". Cover streams via the
artwork pipeline (new ArtworkTarget::FavoritePlaylist). Click is
logged — no playlist detail view in the Slint MVP yet.
Labels:
- Parsed from get_favorites("labels"); the qbz-models Label is
just {id,name}, so a local FavLabel shape picks up the
albums_count. FavLabelRow (icon + name + "N releases") routes
the click into the existing LabelReleasesView via
FavoritesActions::open-label -> navigate_label.
FavTab gains Playlists + Labels; from_route/from_tab_id now resolve
them so the header dropdown and tab bar both lazy-load. Tab bar
shows live counts for all five tabs.
Still deferred (per "finish then polish one by one"): the richer
Tauri options (sort, group-by-artist, owned/following split,
per-row context menus, bulk select, download status).
cc
Wires the Library header dropdown to a real Favorites view. Mirrors
Tauri's FavoritesView.svelte core: a tab bar over per-tab content,
each tab lazy-loaded on first open.
Slint:
- ContentView gains `favorites`. NavMenuEntry gains a `route`
field; the Library dropdown rows carry "favorites-tracks" /
"-albums" / "-artists" / "-playlists" / "-labels". The
HeaderMenuOverlay click bubbles the route up through AppShell ->
AppWindow -> Rust (new header-menu-navigate callback).
- FavoritesState / FavoritesActions globals. Tracks reuse
SearchTrackItem (so the shared SearchTrackRow renders them with
per-row cover art), albums reuse AlbumCardItem + the Discover
AlbumCard, artists use a new FavoriteArtistItem round-portrait
grid.
- New ui/favorites/FavoritesView.slint: title, tab bar with live
counts + active underline, and the three populated tabs.
Playlists / Labels show "Coming soon" pending their backing
views. AppShell routes ContentView.favorites.
Rust:
- crate::favorites controller — load_favorites parses the raw
get_favorites JSON (`{ <type>: { items, total } }`) into typed
qbz-models Track / Album / Artist, maps to the Slint structs.
Lazy per-tab: select-tab and the header route both call
navigate_favorites, which fetches only the opened tab.
- artwork::ArtworkTarget gains FavoriteTrack / FavoriteAlbum /
FavoriteArtist; covers stream in via the shared pipeline.
- main.rs: navigate_favorites + on_header_menu_navigate +
FavoritesActions (select-tab / open-album / open-artist /
play-track / track-action). No new qbz-core method —
get_favorites already existed.
Deferred: Playlists + Labels tab content, full pagination (one 500
page is fetched per tab), favorites preferences (tab order).
cc
Wires the Origin section's location link to the scene-discovery
view. Completes the three artist-network click targets (Musician,
Label, Location).
Slint:
- ContentView gains `location`; NavEntry mirrors with the full
`Location { mbid, area_id, area_name, country, genres, tags }`
payload so back/forward replays the scene.
- LocationViewState / LocationViewActions globals carry the scene
header (label + genre summary) and a paginated artist grid
(LocationArtistItem: qobuz id, name, genres line, image).
- New `ui/location/ArtistsByLocationView.slint`: a scene header
(map-pin + location + genre summary) over a wrapping round-
portrait artist grid with load-more. open-artist routes the
card click to the artist page.
- AppShell routes ContentView.location; new map-pin.svg (Lucide).
Rust:
- `crate::location_view` controller — load_scene, apply_scene,
append_scene, reset_scene, artwork_jobs. Maps LocationCandidate
rows to the grid (qobuz_name fallback to mb_name).
- artwork::ArtworkTarget gains LocationArtist { index }.
- The Origin metadata load now caches LocationParams (area +
affinity seeds) in a cross-thread Mutex; reset_network_sidebar
clears it so a stale click can't fire for the wrong artist.
artist::location_params() exposes it to the click handler.
- main.rs: navigate_location + apply_entry route +
on_location_clicked (reads the cached params) +
LocationViewActions open-artist / load-more.
Backend (prior commit) supplied discover_artists_by_location;
this commit is the Slint + wiring layer.
cc
Ports the core of v2_discover_artists_by_location to QbzCore,
unblocking the Slint ArtistsByLocationView. Reuses the affinity
helpers already living in qbz-integrations (compute_affinity_score,
extract_affinity_seeds, is_broad_genre, genre_summary).
Pipeline:
- Smart area resolution — resolve a city area_id to its parent
subdivision via resolve_parent_subdivision for broader results
(Leyton → England), falling back to the raw area name.
- Drop overly broad genre tags (rock / pop / latin) from the
search set so the query doesn't return the whole country's
catalog; fall back to the raw list only if everything was
broad.
- MB tag+area search per genre, dedupe candidates by mbid,
accumulate affinity score + a multi-genre-hit bonus.
- Sort by score (stable by mbid for paginated calls), page with
offset/limit, validate each against Qobuz (exact normalized
name match, most-albums wins for collisions).
Deferred vs the Tauri command: the scene cache (get/set) and the
`scene-discovery-progress` events. The pure discovery result is
identical otherwise.
cc
Wires the artist network sidebar's Labels section to a real
destination. Mirrors Tauri's LabelReleasesView.svelte core: a label
header (image + name + release count) over a wrapping album grid
with load-more pagination. (Sort / filter Hi-Res / group-by-artist
from the Tauri view are deferred — the core browse experience is in.)
Slint:
- ContentView gains `label`; NavEntry mirrors with
`Label { id, name }`.
- LabelState / LabelActions globals carry the header (id, name,
image-url + decoded image) and the AlbumCardItem grid + total.
- New `ui/label/LabelReleasesView.slint`: label header card,
then the same absolute-position album grid + load-more pattern
the artist/musician views use. Reuses the Discover AlbumCard
(artist-as-subtitle is correct here — a label's catalog is
various artists).
- AppShell routes ContentView.label and forwards open-album +
media-action.
Rust:
- `crate::label` controller — load_label (parallel-ish
get_label_page + get_label_albums), load_more_albums,
apply_label, append_albums, apply_image (label header), and
artwork_jobs. extract_label_image handles the flexible
/label/page image value (string or mega/extralarge/.../small
object), same precedence as the Svelte.
- artwork::ArtworkTarget gains LabelAlbum { index }.
- main.rs: navigate_label + apply_entry route + on_label_clicked
(sidebar) + LabelActions::on_load_more.
- No new qbz-core methods — get_label_page / get_label_albums
already existed.
cc
Wires the sidebar's musician-clicked callback into a real
destination. Mirrors Tauri's MusicianPageView.svelte plus the
"jump to artist page when Confirmed" shortcut.
Slint:
- ContentView gains a `musician` variant; NavEntry mirrors with
`Musician { name, role }` so the back/forward history can
re-fire the page with the original payload.
- MusicianState / MusicianActions globals carry the resolved
musician metadata (name, role, confidence) + the paginated
Appears On grid (MusicianAppearanceItem mirrors the Tauri
interface — album_id, title, artist_name, year,
role_on_album, artwork URL/handle).
- New `ui/musician/MusicianPageView.slint`: header (avatar +
name + role), Bands & Projects chips when populated, and an
Appears On grid built from the same absolute-position pattern
the artist release grid uses. Load more button drives
MusicianActions::load-more.
- AppShell routes ContentView.musician to MusicianPageView and
forwards open-album.
Rust:
- `crate::musician` controller — load_musician, apply_musician,
load_more_appearances, append_appearances, reset_musician,
artwork_jobs. Pure data + UI plumbing; the orchestration lives
in qbz-core.
- artwork::ArtworkTarget gains MusicianAppearance { index } so
the existing artwork pipeline can target the new grid.
- main.rs gains `navigate_musician` (the navigation entry point
apply_entry now also routes to) and `on_musician_clicked`
(does the resolve, jumps to navigate_artist on Confirmed,
otherwise records + navigates to the Musician view).
- MusicianActions::on_load_more wired to load_more_appearances
+ append.
cc
Two new QbzCore methods unblocking the MusicianPageView port — both
direct ports of v2_resolve_musician and v2_get_musician_appearances
from src-tauri/commands_v2/integrations.rs.
musicbrainz_resolve_musician(name, role):
- MB client resolve_artist for the MBID, then a Qobuz artist
search for an exact name match. Confirmed when the Qobuz
artist matches; Contextual when there are appears-on albums
only; Weak when MB knows the artist but Qobuz has nothing;
None otherwise.
- The sidebar's musician-clicked dispatcher uses the confidence
to decide whether to route the click into the artist page
(Confirmed) or into MusicianPageView (Contextual / Weak).
musicbrainz_get_musician_appearances(name, role, limit, offset):
- Wraps QbzCore::search_albums with the same per-row mapping
Tauri does: album_id, title, artwork (large/small fallback),
artist name, year, and the caller's role string stamped onto
each row as `role_on_album`. Returns
`MusicianAppearances { albums, total }`.
cc
Two unrelated things landing together.
Sticky scroll fix:
- The natural-top binding on both jump-nav and the sidebar reads
from `body-row.absolute-position.y` (a live, scroll-aware
lookup). The y formula was `max(natural-top + viewport-y, ...)`
— adding viewport-y on top of an already scroll-aware value
double-counted the scroll. During mid-scroll the bar drifted
ahead of the body row, opening a visible gap and briefly
overlapping the header.
- Drops `+ viewport-y` from both formulas. natural-top alone IS
the viewport-relative y the elements want to occupy; the
max() clamp just keeps them from going above the top.
- Initial position and sticky position are unchanged; only the
transition is fixed.
Artwork right-click menu (artist portrait + album cover):
- Both headers get a TouchArea on the artwork that opens a
PopupWindow + ContextMenu on right-click, positioned at the
cursor. Mirrors Tauri's `oncontextmenu` flow on the artist
portrait and album cover.
- Menu items match Tauri 1:1: Add image / cover, Change + Remove
(when a custom image is registered), divider, Open in
browser, Save as.
- New `crate::custom_artwork` JSON-backed store at
`<data-dir>/qbz/custom_artwork.json` keyed by artist name +
album id (mirror of the Tauri library's
`v2_library_set/remove_custom_*` persistence).
- ArtistState / AlbumState expose `artwork-url` (network URL for
Open / Save) + `has-custom-image` / `has-custom-cover` (drives
the Add vs Change/Remove menu rendering), both populated from
Rust at apply_artist / apply_album time.
- ArtworkActions global + main.rs handlers:
* open-in-browser → `open::that(url)`.
* save-as → `rfd::AsyncFileDialog::save_file()` + reqwest
download + tokio::fs::write.
* add-custom → `rfd::AsyncFileDialog::pick_file()` then
`custom_artwork::set_*` + flip the has-custom flag.
* remove-custom → `custom_artwork::remove_*` + reset flag.
- rfd added with xdg-portal + tokio features. external-link and
image-plus SVGs ported from Lucide.
Custom-image RENDERING (using the picked file in place of the
network artwork) is deferred — the store persists the choice and
the menu reflects it, but the displayed image still comes from
Qobuz. Next pass.
cc
The previous binding pulled the bar 44px up from body-row's top
(`body-row.y - self.height`), which landed it inside the artist
header — eating the action buttons row. The right value is
`body-row.y` itself: the bar takes the top 44px of the 52px
reservation slot that lives as body-row's first child, so it sits
exactly in the gap between the header above and the body below
(Popular Tracks header starts 52px into the body row, giving an
8px breathing gap below the bar). No overlap on either side.
Sidebar natural-top compensates with `+ jump-nav.height` so it
still starts flush with the bar's bottom edge as before.
cc
Brings the JUMP TO bar from a visual placeholder to a working nav,
plus two interaction fixes the user flagged on the previous build.
JUMP TO bar — functional scroll-to:
- JumpNavTab gains an `anchor-y: length` field. JumpNavBar's
tab-clicked callback now emits (id, anchor-y); the parent
(ArtistPageView) scrolls page-flickable to land the section
just below the sticky bar.
- Tab list is built in Rust (`build_jump_tabs` in artist.rs)
from the artist's actual data and pushed to a new
`ArtistState.jump-tabs` model. Tabs are emitted only for
sections that exist (top tracks present, "Discography" /
"EPs & Singles" / "Live Albums" / "Compilations" / "Others"
only when the corresponding release_sections entry is there);
the Playlists tab — not implemented yet — never appears.
- anchor-y estimates use layout-derived constants per section
type (popular-tracks header + visible row count, release grid
headers + ceil(album_count / 5) rows). Not pixel-perfect for
long bios or grids that wrap differently, but lands the
viewport inside the right section.
- Removed the hardcoded ArtistPageView property — tabs and
visibility are now data-driven.
Sticky positioning — dynamic:
- jump-nav.natural-top now binds to
`body-row.absolute-position.y - page-flickable.absolute-y -
self.height`, so the bar always sits flush in the gap above
the body row regardless of artist-header height (long-bio
artists stop pushing the row out from under the bar).
- Sidebar natural-top follows the same body-row anchor, so it
too tracks where the body actually starts.
Search input UX:
- The outer search-icon slot is now removed from layout (via
`if !search-open`, not `visible:`) when the input opens, so
the expanded input runs all the way to the bar's right
padding — right under the user's cursor where the lupa just
was, no leftover gap on the right.
- `changed search-open` auto-focuses the inline TextInput, so
typing starts immediately after clicking the lupa with no
second click in the input.
cc