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
Three targeted fixes from the latest screenshot.
Body offset:
- The 24px head spacer before the body row only nudged content
past the artist header, leaving Popular Tracks' first row sitting
under the JUMP TO overlay (the play/select/more buttons in
particular were behind the bar). Spacer is now 52px so the body
column begins below the bar's natural y + 44 height + a small
gap.
- jump-nav natural-top tightens from 290 to 291 (purely cosmetic
alignment with the new 52px slot above it). Sidebar natural-top
follows to 335 (= 291 + 44) so it still sits flush below the
bar.
Search input width:
- max-open-width drops from 360px to 216px (60% of the previous
cap). The expanded input now reads as a compact box on the
right of the bar instead of a bar-wide slab.
X close affordance:
- Moves INSIDE the search input on the right side, alongside the
leading magnifier glyph. Clicking it both clears the query and
closes the input (calls back into search-clicked, which the
parent already wires to a reset).
- The standalone X button that used to live on the bar's right
edge is gone — the outer button now only ever shows the search
icon when closed, and hides entirely while the inline input is
open (no two competing search affordances).
cc
Two precision fixes from the latest screenshot pass.
Search input:
- The expanded input was claiming the full center-region width
(everything between the JUMP TO label and the close icon),
reading as a "gigante" slab. Caps the open width at 360px and
keeps the right-edge anchor so it expands inward from the
search icon, matching Tauri's `.search-input-container` shape.
Network sidebar top edge:
- natural-top moves from 320px to 334px (= jump-nav natural-top
290 + jump-nav height 44). The sidebar now starts flush with
the bottom edge of the JUMP TO bar instead of sliding behind
its last 14px ("se come un cachito").
cc
Moves the in-page search out of the body column into the JUMP TO
bar itself, with a width animation so the input slides over the
tabs from the right edge on open (and back to 0 on close), matching
Tauri's `.page-search.open` behaviour.
JumpNavBar:
- New `search-text` (in-out) and `search-text-edited` callback so
the parent owns the query string + filter wiring while the bar
owns the input UI.
- Center region is a single Rectangle whose `clip: true` hides the
expanding search-overlay until it has width. The overlay's
width binds to `search-open ? parent.width : 0px` with a 200ms
ease-in-out animation, so the tabs visually disappear behind it.
- Tabs' TouchAreas are disabled while the search is open, so the
visible click target is just the input.
- Search icon flips to an X glyph and stays accent-tinted while
open, signalling "click again to close".
ArtistPageView:
- The standalone in-page search Rectangle in the body column is
removed. The 24px head spacer stays so the loading state /
Popular Tracks header sit at the same y as before.
- JumpNavBar wired: search-text-edited routes through to
ArtistActions.search; search-clicked toggles search-visible and
also clears the query (and re-runs the empty search) on close
so the filter releases as the input disappears.
cc
Two release-grid issues from the latest user pass.
Release cards (Discography / EPs & Singles / Live / Compilations /
Others):
- map_release was leaving year, quality_tier and quality_label as
empty strings, so the shared AlbumCard had nothing to render for
the badge (no Hi-Res / CD pill) and showed the artist name as
the subtitle (redundant on the artist's own page).
- Now populates `year` from `release.dates.original` (first 4
chars), `quality_tier` from `audio_info.maximum_bit_depth` via
the existing `tier()` helper, and `quality_label` as the
"24-bit / 96 kHz" string the badge tooltip expects.
- card_to_item re-routes the year into the AlbumCard's `artist`
field so the subtitle reads "2026" / "2020" / "1997" instead of
"Julieta Venegas" — matches Tauri's artist page.
Load more / View less:
- Centered (was left-aligned), no background and no border, 4px
gap above so it sits separated from the last visible row
instead of looking glued to it. Hover treatment is a simple
text-color flip (Theme.text-primary on hover) rather than the
panel-style bg + border the button had before.
cc
Three follow-ups to the body-row restructure.
Popular Tracks header:
- The header HorizontalLayout had `alignment: space-between` but
no horizontal-stretch on the title cell, so the action cluster
(Play / Select / More) collapsed to zero width inside the new
main-col container. Now uses an explicit horizontal-stretch on
the title cell with the action cluster pinned to its preferred
width, so the three buttons reappear on the right.
Play All wired:
- ArtistState gains an `id` property, set in navigate_artist
before the page data loads. The Play CircleAction now passes
`ArtistState.id` through the media-action callback as the
artist id payload.
- main.rs routes `("artist", "play-top")` to a new
playback::play_artist_top_tracks, which re-fetches the artist
page (so the queue carries the same audio metadata the page
row uses), builds QueueTracks from page.top_tracks, set_queues
them, and starts audio on the first one. Other artist-row
actions (Select / More) stay as their current placeholders for
now.
Multi-select layout:
- Per-track checkboxes move from the right side of the row to
the leading column, replacing the play-cell when multi-select
is on (so the checkbox stays vertically aligned with the same
column the play number normally sits in). This is the project
standard the user called out — checkboxes belong on the
leading edge.
- When multi-select is on, a "Select all" check-all row appears
immediately above the track list, also leading-aligned. The
visual click emits media-action("artist", "",
"select-all-top-tracks") — handler stub will land with the
rest of the selection wiring.
cc
The previous byte-walking implementation pushed `bytes[i] as char`
for every non-tag byte, which mangled every multi-byte UTF-8
sequence into its constituent Latin-1 codepoints. A single "ó"
(U+00F3 = 0xC3 0xB3) came out as "ó"; longer artist biographies
in Spanish / Portuguese were riddled with the glitch chars the
user just flagged ("establecíĀĀª" instead of "estableció", etc).
Rewrites the three byte-walking helpers (normalize_breaks,
decode_entities, and the underlying matchers) to walk by `char`
via `.chars()` + `&str` slicing. Tag detection still uses
`as_bytes()` for fast ASCII matching against `<br...>` and `</p>`,
but only when the leading `<` is ASCII (which it always is) — the
rest of the body passes through char-by-char so accented letters
and curly quotes land verbatim in the output.
Added a regression test (`preserves_multibyte_characters`) that
runs the corrupted biography fragment through strip_html and
asserts the round-trip is lossless.
cc
Three follow-on refinements after the layout restructure.
JUMP TO bar (`ui/primitives/JumpNavBar.slint`):
- New reusable component — same shape Tauri ships above
section-rich pages (artist, library, future label / musician
views): "JUMP TO" label, horizontal list of tabs with an accent
underline on the active one, search icon on the right.
- Presentation-only — sticky positioning is the parent's
responsibility. Tabs and search icon emit callbacks; reuse by
passing a fresh tab list and routing the callbacks.
Artist page wiring:
- JumpNavBar mounted as an overlay sibling of the page Flickable
with the same sticky math as the sidebar: y =
`max(290px + page-flickable.viewport-y, 0px)`. At rest the bar
sits below the artist header; once the user scrolls past the
header, it pins to the top of the page. (This is the reusable
sticky recipe the user asked for — it's wired here for artist
and lifts straight into the next section that needs it.)
- jump-tabs hardcoded with About / Popular Tracks / Discography /
EPs & Singles / Compilations / Playlists — same labels as
Tauri. tab-clicked updates the active-tab visual state; actual
scroll-to-section wiring is a follow-up (needs anchor positions
that depend on per-artist data).
- search-visible flag flips on the bar's search-clicked. The
existing in-page search input is now gated by it, so the search
is closed by default (matches Tauri's `.page-search.open`
behaviour) and opens on click of the JUMP TO bar's search icon.
Popular Tracks "View less":
- The Load more affordance now flips its label to "View less" once
expanded so users can collapse back to the 5-track preview
without scrolling. A small improvement over the Tauri flow
(which only shows Load more).
cc
Restructures the artist page so the gradient header truly covers the
full window width and the network sidebar behaves like Tauri's
sticky aside: anchored to natural-y below the header while the page
is at the top, then pinned to sticky-top once the user scrolls past
the header. The bottom edge stays flush with the now-playing bar.
Layout:
- The outer HorizontalLayout wrapping (Flickable + sidebar column)
is gone. The Flickable is now a direct child of ArtistPageView at
full root.width, so the gradient Rectangle inside it also runs
edge-to-edge. (Tables analogy: header row is colspan=2.)
- Inside the page VerticalLayout, body content (search, popular
tracks, release grids) is wrapped in a HorizontalLayout with a
horizontal-stretch main column on the left and a 300px empty
placeholder on the right when the sidebar is open. The main
column never extends under the sidebar.
Sidebar:
- The sidebar block is no longer a sibling of the Flickable inside
a HorizontalLayout — it's an absolutely-positioned Rectangle
overlay anchored at x = root.width - 300, y bound to
`max(natural-top + page-flickable.viewport-y, sticky-top)`. At
scroll = 0 the sidebar sits at natural-top = 320 (just below the
header). As the user scrolls, the sidebar tracks the page upward
until it pins at sticky-top = 44 (reserving room for the future
sticky JUMP TO bar — harmless until that lands).
- height = root.height - self.y, so the sidebar always extends to
the bottom of the page area (top of the now-playing bar). The
32px bottom inset is gone — no more gap under the sidebar.
cc
Mirrors Tauri's `.page-search` substring filter — Popular Tracks
(title + artist) and every release-section album (title) hide on
each keystroke when they don't match, restore when the query is
cleared.
UI:
- Search row added between the artist header and Popular Tracks,
always visible while artist data is present. Search icon +
inline TextInput, surface-card background with a thin border
that highlights on focus. Placeholder "Search this page…".
State:
- New ArtistActions global with `callback search(string)`, re-
exported through app.slint so main.rs can wire it.
Controller (artist.rs):
- Thread-local FULL_TOP_TRACKS and FULL_RELEASE_SECTIONS cache the
rendered models on the UI thread (same pattern as
AlbumPageView's FULL_TRACKS), populated in apply_artist so the
filter never round-trips to the backend.
- filter_artist(window, query): trim+lowercase the query, then
rebuild ArtistState.top_tracks and ArtistState.release_sections.
Sections whose albums all filter out are dropped from the visible
list, matching Tauri's behavior.
main.rs:
- ArtistActions::on_search wired to artist::filter_artist (same
shape as the existing AlbumActions::on_search wiring).
cc
Tauri's artist page lays Discography / EPs & Singles / Live Albums /
Compilations / Others as grids that wrap into rows — never as
horizontally paginated carousels. The Slint port was reusing the
Discover Carousel here, which paginated horizontally and hid most of
the discography behind chevrons.
New `ui/artist/ReleaseGrid.slint`, modeled on `discover/GridSection`:
- Same DiscoverSection input + album-clicked / media-action
callbacks the Carousel exposed, so swapping is one import + one
component name in ArtistPageView.
- Card grid uses absolute positioning inside a sized Rectangle so a
`for` can render a flow that Slint's layout primitives don't
support directly. Columns recompute on resize:
max(1, floor((width + gap) / (card_width + gap)))
Card sizes match the Carousel for visual continuity (220x266 +
24px gap), so the wider viewport lands ~5 cards per row, the
narrower one ~2.
- Header shows the section title and the count next to it
(mirrors Tauri's `<count>` badge); no sort dropdown yet — per-
section sorting can land in a follow-up.
cc
Two text-side refinements to the artist + album views.
Typography:
- Bumps every scale level by 1px so body text reads at 15px instead
of 14px (legal 13, link 14, body/subtitle 15, button 17, heading
19, title 25, wordmark 29). All views adopt the new scale through
the existing tokens — the artist + album descriptions feel
noticeably more readable without per-component overrides.
strip_html (new shared module `crates/qbz-slint/src/strip_html.rs`):
- Single implementation now used by both album::map_album and
artist::map_artist for editorial copy (reviews, biographies).
The previous album-local helper handled only <br> and </p>; the
artist-local helper handled neither, so multi-paragraph
biographies collapsed into one wall of text.
- <br>, <br/>, <br /> (any case + spacing) -> \n. </p>, </P> ->
\n\n. Stripped tags like <strong>, <em>, <a> still leave their
text inline. (Slint's `Text` is single-style, so true inline
bold/italic isn't possible without splitting into multiple Text
elements — deferred.)
- Broader entity table: amp/lt/gt/quot/apos/nbsp/copy/reg/mdash/
ndash/hellip/curly quotes plus numeric copy variants.
- Runs of 3+ newlines collapse to a paragraph break so stacked tags
don't blow the layout open. Unit tests for each path.
cc
Ports the minimum of src-tauri/src/reco_store needed to honour the
"skip artists I already listen to" gate in the discovery pipeline.
play_history (`crates/qbz-slint/src/play_history.rs`):
- Tiny SQLite store at <data-dir>/qbz/play_history.db with WAL
(ADR-002), holding play_events (artist_id + occurred_at, one row
per audible track start) and artist_names (id -> name upsert).
Lazy open, errors collapsed to log::warn so a missing DB just
means an empty filter — same default Tauri lands on a fresh
profile.
- record_play(artist_id, name) wired into playback::record_recent
so every audible track start increments the per-artist count.
- known_artists(threshold) returns the (HashSet<u64>,
HashSet<String>) pair Tauri's filter expects — qobuz ids for
suppressing validated matches, normalized names for suppressing
MB candidates upstream.
qbz-core::musicbrainz_discover_artists:
- New `known_artists` closure parameter, called once per
invocation. Threshold matches Tauri (>2 plays).
- Filter applied at both stages: MB candidates dropped when their
normalized name is in known_names; Qobuz matches dropped when
their id is in known_ids (mirrors local_known_qobuz_ids check
in v2_get_discovery_artists).
The Tauri reco_store has many other features (favorites, top
genres, home seeds) the Slint shell does not use yet — those stay
out of this commit; only the play-count surface needed by the
sidebar's "You may also like" filter is ported.
cc
The small uppercased "ARTIST" eyebrow above the artist name in the
header is redundant — the context (artist page) and the prominent
name below already say it. Removed the Text node and the 6px spacer
that followed.
cc
QbzCore gains an Arc<Mutex<Option<MusicBrainzCache>>> field plus a
`set_musicbrainz_cache(cache)` setter so the frontend (which owns
the data-dir path) can install the cache after construction.
musicbrainz_get_artist_metadata and musicbrainz_get_artist_relation-
ships now check the cache before fetching and persist successful
results on the way out — mirrors the v2_musicbrainz_get_artist_*
behavior in src-tauri/src/commands_v2/integrations.rs. Missing cache
degrades gracefully: methods skip the lookup and the persist hook
silently when no cache is set.
qbz-slint opens the cache at startup at <data-dir>/qbz/cache/
musicbrainz_cache.db (same path Tauri uses) and hands it to the
core. Failure to open the directory or the SQLite file just falls
back to direct network calls — a warn line in the log surfaces it,
but the sidebar still works.
cc
Two adjustments to land the sidebar truly 1:1 with the Tauri visual.
Top inset now 320px — clears the entire artist header (NavButtons
row + ARTIST label + name + truncated bio + Read more + action
buttons + the 36px spacer before Popular Tracks). The sidebar's top
edge sits inline with where Popular Tracks starts, matching Tauri's
`.network-sidebar-column` natural flow position inside the body
(alongside the sections, never overlapping the header).
Sidebar is always open by default on every artist navigation, with
no cross-session persistence — closing it is a per-view choice only:
- reset_network_sidebar (called at the start of navigate_artist)
flips NetworkSidebarState.open back to true alongside the other
per-artist state resets.
- network_sidebar_prefs.rs is removed; the on_toggle Rust handler
is a no-op (Slint already mutates the open flag directly in the
button click). The previous `<data-dir>/qbz/network_sidebar.json`
persistence file is no longer written or read.
cc
Two refinements requested after the sidebar landed.
Artist biography:
- Adopts the AlbumPageView description pattern. The truncated body
is sized with an explicit width = content-width - 296px (page
padding 32+32 + portrait 200 + header gap 32), so it fills the
horizontal band to the right of the artwork instead of wrapping
narrow.
- content-width is derived: shrinks by 300px when the network
sidebar is open, so the bio band stays inside the visible main
column and never gets clipped behind the sidebar.
- Read more no longer toggles an inline expand. It opens a
centered modal with a Flickable for the full bio plus the
optional "Source: <name>" attribution line — same scrim, panel
sizing, and close-X treatment as the album modal.
Network sidebar height:
- Top spacer goes from 22px to 44px to clear the NavButtons row
at the top of the artist body — same offset Tauri's sticky
jump-nav uses (`top: 44px`).
- Bottom spacer goes from 22px to 32px to mirror the small gap
above the now-playing bar that Tauri's `height: calc(100vh -
180px)` produces. The sidebar is now visibly shorter than the
main column, matching the WebKit build.
cc
Closes the network sidebar with the fifth section — tag-based "You may
also like" discovery, with a per-tag dismiss store so a thumbs-down
sticks across artist pages that share the same primary genre tag.
Backend (`qbz-integrations` + `qbz-core`):
- DiscoveryArtist and DiscoveryResponse DTOs added to
qbz-integrations::musicbrainz::models.
- QbzCore::musicbrainz_discover_artists ports
v2_get_discovery_artists end to end (sans the RecoState local
"play count > 2" filter, which has no equivalent in the Slint
shell yet): primary-tag search, exclude seed + similar +
dismissed, deterministic shuffle keyed on seed_mbid, validate
each candidate on Qobuz, fall back to the secondary tag if we
have fewer than 5 matches.
- normalize_artist_name re-exported at the qbz-core crate root so
the dismiss handler in qbz-slint can use the same key shape that
the discovery pipeline filters on.
- Deterministic shuffle implemented with a simple xorshift PRNG
seeded from a stable hash of seed_mbid — keeps qbz-core free of
the rand dep.
Slint controller (`crates/qbz-slint/src/`):
- discovery_dismiss.rs — small JSON store at
`<data-dir>/qbz/discovery_dismiss.json` keyed by lowercased tag
-> list of normalized artist names. dismissed_for_tag is passed
into the discovery call as the filter source; dismiss() is
invoked by the thumbs-down handler.
- artist::load_mb_discovery / apply_mb_discovery /
remove_discovery_artist plumb the pipeline result through the
standard load-off-thread / apply-on-UI split. Discovery state
includes primary_tag so the dismiss handler can persist under
the right key.
- main.rs chains discovery after relationships in the same MB
enrichment task. NetworkSidebarActions.discovery-dismissed (now
carrying mbid + name) normalizes the name, persists the
dismiss, and removes the row from the visible list.
Slint UI (`ui/state.slint`, `ui/artist/ArtistPageView.slint`):
- discovery-tag property added to NetworkSidebarState.
- YOU MAY ALSO LIKE section after Relationships — one row per
candidate (SidebarLink + a 24x24 thumbs-down button on the
right). Clicking the link navigates via the artist-clicked
callback when a Qobuz id is present.
- thumbs-down.svg icon added to ui/assets/icons (Lucide
ThumbsDown).
cc
Wires the third MB-driven section: Members & Former, Member Of, and
Collaborators, mirroring Tauri's RELATIONSHIPS block.
Controller (`crates/qbz-slint/src/artist.rs`):
- load_mb_relationships(runtime, mbid) — calls QbzCore's
musicbrainz_get_artist_relationships, then maps each section
independently with group_relations.
- group_relations dedupes by mbid combining roles, preserves first-
seen order, and computes the per-row role + tooltip:
* role = first attribute, or the section default ("Band Member"
/ "Band" / "Collaborator") when MB has no attributes.
* tooltip = roles joined with ", " plus the period in parens
("Vocalist, Guitarist (1965 - 1969)"); falls back to just the
period or to the name.
- format_period mirrors Tauri's `{begin || '?'} - {end || 'present'}`
when at least one of the two is set, otherwise empty.
main.rs:
- The MB enrichment task now chains: resolve + metadata first, then
relationships with the resolved mbid. Both loading flags flip
together so the sidebar shows a single "Loading…" line per section
while the calls run.
ArtistPageView.slint:
- RELATIONSHIPS section, gated by mb-available + (loading || has-
data). Three sub-groups, each with a secondary label
("Members & Former" / "Member Of" / "Collaborators"), shown only
when their list is non-empty.
- Members and Collaborators use the User icon, Member Of uses the
Music icon (matches Tauri's Lucide imports).
- Each row reuses SidebarLink, emits NetworkSidebarActions.musician-
clicked(name, role) — logged-only until MusicianPage lands.
cc
Extracts the two Qobuz-driven sidebar sections from /artist/page data
the artist controller already receives and wires them into the
NetworkSidebar.
Controller (`crates/qbz-slint/src/artist.rs`):
- Labels collected during the existing release-bucketing loop,
matching Tauri's extractLabelsFromPageReleases: only the "album"
release group, only releases attributed to this artist, dedupe by
label id, sorted by name. Each label exposes {id, name}.
- Similar artists pulled from page.similar_artists.items as
{id, name}. No artwork on the sidebar rows (Tauri only renders a
User icon + name there).
- ArtistData / ArtistState gained `labels` and `similar_artists`;
reset_artist clears them on artist change.
Slint (`ui/state.slint`, `ui/artist/ArtistPageView.slint`):
- New LabelEntry and SimilarEntry structs alongside ArtistState.
- New SidebarLink local component — small leading icon (12px) +
elided name, surface-elevated hover, mirrors Tauri's
`.sidebar-artist-link`. Used by Labels (Disc), Similar (User),
and the upcoming Relationships rows.
- Labels section: title + items or "No label info" placeholder.
- Similar Artists section: title + items, with "Loading…" while
the artist page itself is loading and "No similar artists found"
once loaded with an empty list.
main.rs:
- on_artist_clicked now navigates: records nav history and calls
navigate_artist, same flow as the top-level on_open_artist. The
target view exists in Slint already (unlike LabelReleases /
ArtistsByLocation / MusicianPage, which stay logged-only).
cc
Wires the artist controller to call QbzCore's MusicBrainz orchestration
and feed the Origin section of the network sidebar, plus typed click
callbacks for every section (logged-only until the target views land).
Controller (`crates/qbz-slint/src/artist.rs`):
- reset_network_sidebar() — clears sidebar MB state on artist change.
- load_mb_metadata(runtime, name) — checks the client's enabled flag,
resolves name -> mbid via musicbrainz_resolve_artist, fetches
metadata via musicbrainz_get_artist_metadata, maps into MbOrigin
(is_person derived from artist_type, dates formatted as "1976",
"May 1976" or "May 14, 1976" matching Tauri's en-US output).
- apply_mb_metadata() / apply_mb_unavailable() — push into
NetworkSidebarState on the event loop.
main.rs:
- navigate_artist now resets the sidebar before loading and spawns
the MB enrichment in parallel with artwork. Origin loading flag
flips to true before the resolve, back to false on success or
on MB-unavailable.
- NetworkSidebarActions click callbacks (location, label, artist,
musician, discovery dismiss) wired to log() with full payload —
placeholder until ArtistsByLocation/LabelReleases/MusicianPage land
in Slint.
ArtistPageView.slint:
- Sidebar body becomes a Flickable + VerticalLayout (so the upcoming
Labels / Similar / Relationships / Discovery sections can overflow
cleanly).
- Origin section renders three optional rows — Born/Founded,
Born in/Founded in, Died/Disbanded — with the same uppercased
label + right-aligned value pattern Tauri uses. The location row
is clickable when the location precision is finer than country
or a city is present; click emits NetworkSidebarActions.location-
clicked with the artist's mbid.
- Section is gated by NetworkSidebarState.mb-available, so a
disabled MB integration or an unmatched artist hides Origin
entirely (next phases fill Labels/Similar with Qobuz data that
does not depend on MB).
Adds qbz-integrations as a direct dep of qbz-slint so the controller
can match on ArtistType / LocationPrecision without going through a
re-export shim.
cc
Two adjustments to land the sidebar shell closer to the Tauri visual.
1. Default open. The artist network sidebar is one of the visible
improvements over a generic Linux music player, so it should be on
out of the box. `network_sidebar_prefs::load_open()` now returns
`true` when the prefs file is missing, the Slint global also
defaults to open, and main.rs reads the persisted value at startup
to seed NetworkSidebarState.open. Users who close it have the
choice persisted as before.
2. Vertical inset. The sidebar is now wrapped in a VerticalLayout with
a 22px spacer at the top and bottom, so it does not occupy the full
ArtistPageView height. Mirrors Tauri's `height: calc(100vh - 180px)`
which leaves room for the sticky jump-nav at the top and the
now-playing bar at the bottom.
cc
Restructures ArtistPageView's outer layout into a HorizontalLayout
where the existing Flickable becomes a horizontal-stretch column and a
fixed 300px sidebar opens on the right when NetworkSidebarState.open
is true — mirroring Tauri's .artist-body { content + .network-sidebar-
column } flex row.
Sidebar shell:
- 300px column with a 1px left border in border-subtle, surface-card
background.
- Header row (44px tall, surface-main background, separator line
below): uppercased "ARTIST NETWORK" label on the left, 28x28 close
button on the right using the panel-right-close icon (same icon
Tauri renders via Lucide PanelRightClose).
- Close button mirrors the header toggle: flips
NetworkSidebarState.open to false and emits
NetworkSidebarActions.toggle so the Rust side persists.
- Body is an empty stretch placeholder; Origin / Labels / Similar /
Relationships / Discovery sections land in the following phases.
Asset: panel-right-close.svg ported from Lucide (24x24 viewBox,
stroke-width 2, same path as Tauri's lucide-svelte import).
cc
Adds the Network button to the artist action cluster (Heart / Build
collection / Radio / Network), wired to NetworkSidebarState.open.
While the sidebar is open the button stays "active" — accent-tinted
icon over the same surface a hover would produce — mirroring Tauri's
`.network-btn.active`. The CircleAction primitive grew an `active`
prop to express this without bespoke per-button styling; existing
buttons default it to false and are unaffected.
The click toggles NetworkSidebarState.open and emits
NetworkSidebarActions.toggle(). Rust persists the new flag through
network_sidebar_prefs::set_open() so the sidebar reopens in the same
state the user left it.
Asset: element-connect.svg copied from the Tauri `static/` folder
verbatim — same icon Tauri renders for the button.
Hide (blacklist) button stays out of the cluster until the
artist_blacklist module migrates out of src-tauri. The sidebar
itself, its content, and the controllers that fill the state are
landed in the following phases.
cc
Defines the state shape and callbacks the artist network sidebar will
fill in. No view changes yet — the sidebar itself, its toggle button,
and the controllers that populate the state are landed in the following
phases.
Slint side (`ui/state.slint`):
- MbOriginData / MbRelationship / MbRelationshipsData / DiscoveryArtist
structs.
- NetworkSidebarState global with open + mb-available + mb-mbid +
per-section loading flags + the typed data structs.
- NetworkSidebarActions global with the six callbacks the sidebar
will emit (location-clicked, label-clicked, artist-clicked,
musician-clicked, discovery-dismissed, toggle).
Rust side (`crates/qbz-slint/src/network_sidebar_prefs.rs`):
- Tiny JSON store at <data-dir>/qbz/network_sidebar.json with two
functions, load_open() and set_open(open), mirroring the recently.rs
pattern. Failures degrade silently.
cc
Adds qbz-integrations as a dependency of qbz-core and embeds a
MusicBrainzClient on the QbzCore struct (initialized in new()). The
client's own enabled flag governs whether requests fire; calls return
the integration-layer ServiceUnavailable error when disabled.
New methods on QbzCore<A>:
musicbrainz_is_enabled()
musicbrainz_set_enabled(enabled)
musicbrainz_resolve_artist(name) -> Option<ResolvedArtist>
musicbrainz_get_artist_metadata(mbid) -> ArtistMetadata
musicbrainz_get_artist_relationships(mbid) -> ArtistRelationships
The metadata method calls location::extract_metadata on the full artist
response and then resolves the real country from the begin_area
hierarchy when a city-level location was found (MB's "country" field is
where the artist is currently active, not where they were born/formed).
The relationships method splits relations into members / past_members /
groups / collaborators by relation_type and direction, mirroring
v2_musicbrainz_get_artist_relationships exactly.
No cache wiring yet — every call hits the network. A cache wrapper can
be plugged in later via a setter without changing the method
signatures.
cc
Pure-logic move of two helper modules out of src-tauri/src/musicbrainz/
into the qbz-integrations crate, where the MusicBrainzClient and DTOs
already live:
src-tauri/src/musicbrainz/location_discovery.rs
-> crates/qbz-integrations/src/musicbrainz/location.rs
src-tauri/src/musicbrainz/genre_normalization.rs
-> crates/qbz-integrations/src/musicbrainz/genre.rs
The functions exposed are unchanged: extract_metadata,
compute_affinity_score, build_scene_cache_key, format_life_span_date
(from location); normalize_genre, is_broad_genre, extract_affinity_seeds,
genre_summary (from genre). Imports in src-tauri/src/commands_v2/
integrations.rs are repointed to the new home; the legacy file
declarations are removed from src-tauri/src/musicbrainz/mod.rs. No
behavior change.
Unblocks the Slint side from reaching the same helpers via qbz-core
without depending on src-tauri.
cc
Groups releases into five sections (Discography, EPs and Singles, Live
Albums, Compilations, Others). Releases attributed to a different
main artist inside a group are filtered out, and the release id is
deduped across groups so the same album does not appear twice when
Qobuz lists it in multiple buckets. Compilations come from
tracks_appears_on (albums by other artists that include this artist),
deduped by album id.
ArtistData and the ArtistState global gain a bio_source field; the
ArtistPageView renders it as a small "Source: <name>" line under the
Read-more toggle when present (TiVo etc).
cc
The qbz-cache and qbz-cmaf entries on qbz-player landed with the CMAF
playback work yesterday but the lockfile sync was missed. No code
change, lockfile only.
cc
The Popular Tracks list now caps at 5 rows with a Load more button
instead of dumping 30. Each row gains a Select button (foundation for
multi-select — first surface), and the more-button is always visible
(no longer hover-revealed) so all three controls (play / select /
context) read together. AlbumTrackItem gained a selected: bool and
media-action(\"track\", id, \"toggle-select\") flips the row state via
the handler on ArtistState.top-tracks.
cc
Artist release cards were rendered with empty artwork because no
artwork-pipeline target existed for ArtistState. Added
ArtworkTarget::ArtistRelease and an artist::artwork_jobs builder;
navigate_artist now spawns the cover-download jobs after applying the
page data, so every section grid fills in progressively.
Biography truncation: artist.rs precomputes a word-boundary bio_short
(360 chars), ArtistState exposes bio-short and bio-truncated, and
ArtistPageView shows the short variant with an inline Read more /
Read less toggle.
cc
Home, Album and Artist views now show the rotating LoadingSpinner (same
as search) above a label while the initial load runs, replacing the
bare Loading… text.
The top-result-artist dedupe moved from map_search_all into apply_search
and replace_category: the full artists list is kept in SearchState.artists
(used by the Artists tab grid), and a separate artists-carousel drops the
duplicate next to the Most-popular hero on the All tab.
cc
- ArtistCard rebuilt 1:1 with Tauri SearchView.artist-card: 160x220
inside a surface-card container, 12px corners, 120px circular avatar
with a gradient placeholder, hover swaps to surface-elevated. The
Follow chip (rectangular per ADR-008) sits below the name when the
user does not follow the artist.
- Search loading state uses a rotating spinner (Lucide LoaderCircle on
Slint transform-rotation) matching the Tauri spinner — 1s linear
infinite. The search header (title + tabs + filter radios) collapses
while the initial load runs so the chrome does not show through.
- Search history: live debounce + submit now use
nav::push_or_replace_search, so the latest query is the single Search
entry in history. Back from an opened album lands on that search.
- Track row backgrounds use the OR-d row-hovered (cell / fav / dl /
more), so hovering the more button no longer drops the row tint.
- replace_category artists arm re-applies the top-result dedupe, so
filtering does not bring the duplicate back.
cc
New LoadingDots primitive (three pulsing dots over an optional label)
shown while a search is running. The result sections fade in once the
results arrive instead of showing empty placeholder colors. The
LoadingDots component is reusable for other heavy loads (large grids,
playlist manager) when they get the same treatment.
The header nav-tab menu now closes after 1s of pointer idle (was 1.5s)
per the project rule that no menu stays open more than 1s idle.
PopupWindow context menus already close on click-outside, which is the
idle behaviour for click-triggered popups.
cc
New shared TrackContextMenu primitive with every option the Tauri
TrackMenu shows (flat — no submenus): Play now / Play next / Add to
queue / Create radio / Add to library / Add to mixtape / Add to
playlist / Share Qobuz link / Share Song.link / Make available offline
/ Go to album / Go to artist / Track info. Items already wired in the
MVP (play / play-next / queue / favorite) fire as before; the rest emit
their media-action and stay no-op until the backend lands. Used by
SearchTrackRow, AlbumPageView TrackRow, and ArtistPageView TrackRow.
cc
- Follow button on the artist card is no longer a pill — Radius.sm
rounded rectangle, matching the rest of the app (new ADR-008).
- PlaylistCard sized identically to AlbumCard (220x266) with the same
220px artwork area; collage and the grid/carousel updated to match.
- Track row play overlay now appears anywhere over the row, not only
when the pointer is directly over the cell. Slint does not propagate
has-hover to ancestor TouchAreas, so each row OR-s its own ta with
every nested TouchArea (play-cell, fav/dl/more) into row-hovered.
- EqualizerBars sit on a fixed 16px baseline so the animation stays
perfectly centered while the bar heights oscillate.
- TrackPlayCell exposes out hovered so rows can OR it cleanly.
- Search dedupes a top-result artist that is also the first carousel
entry, removing the redundant duplicate.
cc
New shared TrackPlayCell for the track-row leading slot, ported 1:1 from
the Tauri track rows: at rest it shows the cover thumbnail or track
number; on row hover a play triangle; for the current track an animated
three-bar equalizer, swapping to a pause glyph on hover. Clicking a
non-current cell plays the track, clicking the current one toggles
play/pause. Wired into the search, album-page and artist-page track
rows.
cc
- The most-popular row used alignment:start, which packed the artists
carousel to its preferred width (one card). Dropped it so the
carousel stretches and shows as many cards as fit.
- The searchType filter only re-queried on per-type tabs (none on All,
so it did nothing). It now re-queries albums/tracks/artists with the
search_type and replaces all three lists, so it applies on every tab.
cc
- Most popular: a crowned hero inline with the artists carousel; its
cover now resolves (a dedicated artwork target was missing).
- Artists: a real carousel on the All tab, ArtistCard restyled to the
Tauri For-You tile with a Follow chip (favorites the artist) shown
when the user does not follow it.
- Playlists: a PlaylistCarousel of PlaylistCard, with a count-based
PlaylistCollage (1/2/3/4-up) instead of a flat row list.
- Carousels gained a View all link; result counts removed from titles.
- searchType filter is a styled radio row (ring + accent dot) at the
top right with a clear button, not a dropdown.
- Title fixed to Search Results.
- QbzCore::favorite_artist_ids backs the follow state.
cc
The results view was bare text rows — nothing like the Tauri search.
Rebuilt it from the components the successful views already use:
- Albums: a Carousel of AlbumCard cover cards (All tab) and an AlbumCard
grid (Albums tab).
- Artists: new ArtistCard (circular portrait) + ArtistCarousel; an
ArtistCard grid on the Artists tab.
- Tracks: SearchTrackRow with a position number.
- The history nav pair (NavButtons) added to the view toolbar.
cc
The Albums / Tracks / Artists tabs get a QbzSelect filter (No filter /
Main artist / Performer / Composer / Label / Release name). Changing it
re-queries that category from offset 0 with the search_type and replaces
the list; load-more carries the active filter forward. The filter
resets on every new search. search_type_for_filter is unit-tested.
cc
The artwork module gains SearchAlbum/Track/Artist/Playlist targets;
navigate_search and load-more build cover-download jobs and hand them to
spawn_loads. Covers resolve through the shared qbz_cache image cache
(disk-backed, never re-downloaded) and apply per row, so a late arrival
never resets a list.
cc
The Load more button now fetches the next 20-item page for the active
category via the per-type QbzCore search methods and appends onto the
existing VecModel, so already-loaded rows and their artwork are
untouched. category_for_tab maps the tab index to a category and is
unit-tested.
cc
The collapse toggle is a bare icon; the window-edge margin (Spacing.md)
was wider than its ~10px gap to the Discover tab. Drop the tab cluster
x to Spacing.xs so the toggle glyph sits symmetrically.
cc
SearchResultsView is now a real results page: a five-tab strip (All /
Albums / Tracks / Artists / Playlists), an All-tab dashboard (top-result
hero plus a capped preview of each category with View more), and
per-type tabs showing the full loaded list. Album and playlist result
rows are file-local components; tracks reuse SearchTrackRow, artists
reuse SlimCard. The load-more button is in place; its callback is wired
in the next task.
cc
A search-owned track row modelled on the album-page track row, but the
leading slot is a cover thumbnail (search tracks span many albums).
show-artwork keeps the slot configurable for a future shared component.
Compiled once SearchResultsView imports it (next task).
cc
SearchResultsView renders the four SearchState categories; mounted in
AppShell on ContentView.search. Phase 1 is now functional end to end:
header input -> debounced search -> QbzCore::search_all -> results.
Phase 2 builds this into the tabbed dashboard.
cc
The header TextInput now submits on Enter (records history, opens the
results page) and live-searches on edit. Live search debounces 300 ms
via a Slint Timer and requires 2+ characters. A monotonic version
counter guards navigate_search so a stale load cannot overwrite newer
results.
cc
navigate_search mirrors navigate_album: reset SearchState, switch to the
search view, run the search off-thread, apply on the event loop. The
apply_entry stub arm is replaced with the real call so history
back/forward re-runs searches.
cc
search.rs follows the album.rs three-stage pattern: load_search fetches
a combined search via QbzCore on a worker thread, the map_* layer turns
domain types into plain Send rows, and apply_search writes SearchState
on the event loop. The map_* functions are unit-tested. Wired into
navigation in the next task.
cc
SearchState/SearchActions globals, SearchTrackItem/SearchPlaylistItem
structs, and a search variant on ContentView. Re-exported from app.slint
so the Rust layer can populate them. Nothing consumes them yet.
cc
search_all wraps catalog_search, parses the four category pages and the
most_popular hero, and drops blacklisted artists. The blacklist is a
parameter (BlacklistFilter) so Search does not depend on the un-migrated
artist_blacklist module.
cc
SearchAllResults and MostPopularItem now live in qbz-models; the Tauri
helpers re-export them under the legacy V2 aliases so command code and
its response serialization are unchanged.
cc
Icon buttons (header lyrics/queue/hamburger, queue footer) centered
their glyph at a fractional offset (e.g. (36-19)/2 = 8.5px), which
renders blurry — the offset is now rounded to whole pixels. The
header tab spacing dropped from 4px to 2px. The Discover toolbar's
back/forward pair drifted 3px from where the album/artist/settings
views place it; both toolbar clusters now anchor their vertical
center to y=25 so the [<] [>] pair holds one position across views.
cc
The click-outside scrim was a full-window TouchArea — it covered the
tabs and intercepted their hover events, so hover-switching between
menus still did not work (same failure mode as the old PopupWindow
grab). The scrim now starts below the header, leaving the tabs
hoverable. The panel also dropped from header-height + 6px to 45px,
removing the unjustified gap between the tab and its dropdown. Header
tab labels lost another 2px of right padding.
cc
Add HeaderMenuOverlay, a plain in-window Rectangle rendered as the last
child of the AppShell root so it draws on top of the header and content
region. Unlike a PopupWindow it does not grab the pointer, so the header
tabs stay hoverable while a menu is open — hovering from one tab to
another overwrites HeaderMenuState and switches the menu instantly.
Behaviour preserved from the old PopupWindow menus:
- hover-to-open and click-to-open
- 1.5s idle auto-close, restarted on pointer activity over the panel
or its rows and on every tab (re)publish via a changed handler
- click-outside-close via a full-window transparent scrim
- Discover home-item row still switches to ContentView.home
- glyphs on all menus except Discover
Menu panel visuals are unchanged: surface-main bg, 1px border-muted,
Radius.sm, 24px drop shadow, 188px wide.
cc
The four header nav tabs each owned a PopupWindow for their dropdown.
A PopupWindow grabs the pointer while open, so hovering a sibling tab
delivered no hover event and the menus could not be hover-switched.
Add a HeaderMenuState global (open-index, anchor-x, items, home-item)
and the NavMenuEntry struct to state.slint. Tabs now write their
identity into the global on hover/click instead of showing a popup;
the menu panel itself will be rendered by a separate in-window overlay.
Move NavMenuItem into its own file so the overlay can reuse it.
cc
The Discover toolbar's side layouts had no explicit height, so they
filled the 56px bar and y resolved to 0 — the tab pill, genre filter
and gear stuck to the top instead of centering. Both now carry
height = preferred-height.
The header sidebar toggle dropped from a 19px to a 14px icon: it now
matches the nav-tab icon size and tint, and the 30px box centers it
on whole pixels (was 5.5px → blurry).
The queue row's click TouchArea was declared after the content, so it
sat on top and ate the context-menu button's clicks — the [...] just
skipped to the track. It is now declared before the content, so the
menu button keeps its own clicks.
cc
Add a "Play next" ContextMenuItem above "Add to queue" in every menu
that offers queueing: the album page track menu, the artist page track
menu, and the discover album card menu. Play next uses the list-plus
icon and Add to queue switches to list-end, matching Tauri's ListPlus /
ListEnd icon mapping. The queue sidebar track menu is left untouched.
cc
Add play_track_next and enqueue_album_next to playback.rs, calling the
core's add_track_next to insert after the current track. Album play-next
inserts tracks in reverse so they land in order, mirroring Tauri's
v2_add_tracks_to_queue_next. Route ("track"/"album", "play-next")
through the on_media_action handler. Add the lucide list-end icon used
for the existing "Add to queue" item.
cc
Search placeholders rode at the top of the input because the text
had no height to center within — they now span the box like the
TextInput. The queue row context-menu trigger is always visible with
a proper 32px hit area (matching the album track rows) instead of a
hover-only 24px dot cluster, so a near-miss no longer falls through
to the row and skips the queue.
cc
The sidebar collapse toggle is now an icon-only button at the far
left of the header, before the Discover tab. Removed from NavButtons,
which is now just the back/forward history pair.
cc
The right controls (genre filter + gear) used x = parent.width -
self.width - 32px on a width-less HorizontalLayout, which fills the
toolbar so self.width = full width and the cluster landed far left,
on top of the history arrows. Side layouts now carry an explicit
width = preferred-width. The three-tab bar is no longer centered: it
sits next to the history buttons with a 16px gap. The home-gear icon
grew 2px.
cc
Replace the header search field's static placeholder text with a real
TextInput plus an empty-state placeholder, matching the album
track-search pattern so the field clearly reads as an input. Reduce the
search field width to 80% of its prior size (224/320px to 179/256px).
cc
Add a 1.5s idle-close timer to each header nav-tab dropdown. The timer
is armed when the menu opens and reset on any pointer hover or movement
over a menu row; on timeout the popup closes itself. Single-open is
already enforced by Slint, since only one PopupWindow can be open per
window, so sweeping across hover-to-open tabs closes the previous menu
automatically. Hover-to-open and click-to-open both remain working.
cc
Anchor the Discover toolbar's history buttons and genre/gear controls
to the toolbar edges and absolutely center the three-tab nav pill so it
sits at the true horizontal center. Swap the sort gear icon to the
home-gear asset.
cc
Make the sidebar collapse toggle a bare icon button with no background
pill, brightest text-primary tint, and a 2px larger icon. Restyle the
back/forward history buttons to match the Discover carousel page
navigators (circular elevated buttons) instead of the tab pill style.
cc
Port Tauri's prefetch + gapless design into the Slint playback
controller. Gapless only works when the upcoming track is a cache hit
(plays via play_data), so prefetch is a prerequisite.
Prefetch: after_track_change now calls kick_prefetch, which peeks the
next 2 upcoming queue tracks and background-downloads each uncached,
non-local one into the player cache via Player::prefetch_into_cache.
Concurrency is bounded to 2 by a shared semaphore. Mirrors Tauri's
spawn_v2_prefetch (PREFETCH_LOOKAHEAD / MAX_CONCURRENT_PREFETCH).
Gapless trigger: the poll loop watches the player event; when
gapless_ready is set and gapless_next_track_id is 0, it resolves the
next upcoming track, fetches its bytes (L1 -> L2 -> CMAF) via
fetch_for_gapless, and calls Player::play_next. A gapless_requested_for
guard stops the 450ms ticker from re-firing while the download runs;
the current track is never queued as its own next.
Seamless-transition detection: when the engine performs the gapless
handoff the track changes while is_playing stays true (no stop). The
loop detects the track-id change and advances the core queue pointer,
refreshes now-playing metadata and the sidebar, and kicks prefetch for
the new track's successors -- without calling the audible play path.
The existing stop-then-play auto-advance still handles the non-gapless
(first/streamed track) case; gapless_requested_for is reset there too.
cc
Add three public Player methods that drive gapless playback without
touching the audio thread:
- prefetch_into_cache: download an upcoming track fully into L1/L2
cache so it later plays via play_data (cache hit) rather than the
streaming path. CMAF download_full first, legacy full download as
fallback. No-ops on streaming_only / already-cached / in-flight.
- is_track_cached: cheap L1/L2 membership check for the controller.
- fetch_for_gapless: resolve a track's bytes for a gapless handoff
via L1 memory then L2 disk then CMAF download_full. Ports the
L1/L2/CMAF tiers of Tauri's v2_play_next_gapless.
All three reuse existing primitives (audio_cache, download_audio);
the protected audio-thread command handlers are untouched.
cc
Replaces the LINE Seed JP probe with Inter 18pt (Regular/Medium/
SemiBold/Bold static faces, compile-time embedded). Inter is a clean,
screen-tuned UI face; with the femtovg #5177/#11335 fixes it is the
candidate for the final look. FONT_TEST_INTER toggles back to the KDE
system font.
cc
Footer now carries the five Tauri controls with Tauri's icons — clear
(trash-list), save (add-to-list), infinite, sleep timer (clock), and
search; the timer button is visual-only for now. Queue rows gain a
context menu 1:1 with the Tauri queue track menu (Remove from queue,
Stop after this, Remove all after, Add to playlist, Track info); only
Remove is wired, the rest are placeholders.
cc
Moves off Skia back to femtovg and bumps Slint to 1.16: that release
carries the femtovg text-rendering fixes (slint-ui/slint #5177 pixel-
grid alignment and #11335 0.5px tearing / inconsistent glyph spacing).
Skia never received that treatment and rendered text slightly worse.
cc
qbzd (the dormant TUI crate) pinned ratatui -> unicode-width =0.2.0,
which blocked upgrading Slint past 1.14. Dropped from the workspace
members; the directory stays on disk, just no longer built.
cc
The Output Device dropdown, on the ALSA backend, listed devices flat,
unordered, with the BP badge on too many rows. Port the Tauri
DeviceDropdown.svelte ALSA behaviour:
- Regroup devices into Defaults / Bit-perfect (Hardware / Digital) /
Plugin Hardware / Other Outputs, in that order, with a section header
above the first row of each non-empty section.
- Align device_is_bit_perfect (the BP badge rule) with the Tauri ALSA
bit-perfect group: hw: / front:CARD= / iec958: only. hdmi: and
sysdefault: route through plugins and no longer get the badge.
- The regrouped ids stay index-aligned with SettingsMaps.devices, so the
settings-select("device", i) callback still resolves to the right id
and selected-device persistence is unaffected.
cc
QbzSelect gains an optional `groups` property parallel to `options`: a
non-empty entry renders a non-selectable, muted-caps section-header row
above that option in the open list. The header is plain text, no pill
background, matching the QBZ design rule.
The popup now self-sizes from the list layout's preferred height so the
extra header rows are accounted for. `groups` defaults to empty, so the
backend and sample-rate selects (which never set it) render unchanged.
cc
/proc/asound/cards lists every registered sound card, including
capture-only hardware (USB webcams, microphones). Those expose only
pcmXc capture nodes, so read_card_pcm_devices yields an empty playback
list for them. Without a guard such a card still got a bogus
sysdefault:CARD=<name> output entry, making a webcam appear as a
selectable output device.
cc
Slint 1.14 has no public register_font API, so the .ttf is imported
in app.slint (compile-time embed + registration) and selected via the
FONT_TEST_LINESEED toggle in main.rs. Flip the toggle to false to
restore the KDE system font.
cc
The nav-tab dropdowns now open when the pointer enters the tab, no
click required. NavTab gained a hovered callback fired from a
changed has-hover handler; click-to-open still works.
cc