Commit Graph
306 Commits
Author SHA1 Message Date
vicrodh c19a5b84dd feat: For You — radio, album suggestions, rediscover, more-from-library
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
2026-05-21 06:43:53 -06:00
vicrodh 83b7721b10 feat(slint): For You Qobuz Mixes row
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
2026-05-21 02:01:23 -06:00
vicrodh c27f6e1b9f feat(slint): For You Spotlight section
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
2026-05-21 01:51:31 -06:00
vicrodh 85ecf79a2d feat(slint): dedicated For You view with artist sections
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
2026-05-21 00:33:11 -06:00
vicrodh 066890a7dd feat(slint): Discover tabs are history entries
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
2026-05-20 23:35:26 -06:00
vicrodh 21a909185f fix(slint): genre popup grew to full window height
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
2026-05-20 23:35:26 -06:00
vicrodh b4c99e1d88 feat(slint): flesh out Discover For You with real personalized sections
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
2026-05-20 23:22:03 -06:00
vicrodh 9fce0939d1 feat(slint): genre filter advanced view + child-genre filtering
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
2026-05-20 23:06:43 -06:00
vicrodh 880493b73d feat(slint): Discover genre filter (simple view)
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
2026-05-20 22:43:16 -06:00
vicrodh 235c61bf80 feat(slint): drag-to-scroll the slim grid carousels
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
2026-05-20 22:23:53 -06:00
vicrodh 20b13dbbf1 feat(slint): drag-to-scroll album carousels
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
2026-05-20 22:15:10 -06:00
vicrodh 449d3c9e2a feat(slint): enrich recently-played store with quality + ids
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
2026-05-20 22:15:10 -06:00
vicrodh 2dc6726c3b feat(slint): discover home loading skeleton + paginated slim carousels
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
2026-05-20 21:58:36 -06:00
vicrodh 1efa6fc745 feat(slint): Discover Editor's Picks + For You tabs
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
2026-05-20 21:33:06 -06:00
vicrodh 93edce305c feat(slint): Favorites Playlists + Labels tabs
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
2026-05-20 21:20:53 -06:00
vicrodh a40c6ce569 feat(slint): Library > Favorites view (Tracks / Albums / Artists)
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
2026-05-20 20:53:37 -06:00
vicrodh cdb93341d0 feat(slint): ArtistsByLocationView (Port 3 complete — third click target)
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
2026-05-20 20:31:08 -06:00
vicrodh 302f708694 feat(slint): LabelReleasesView (Port 3, second of three click targets)
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
2026-05-20 20:09:09 -06:00
vicrodh cfe083898d feat(slint): MusicianPageView (Port 3, first of three click targets)
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
2026-05-20 19:17:51 -06:00
vicrodh c4b6acb760 fix(slint): smooth sticky scroll + artwork right-click menu
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
2026-05-20 18:24:39 -06:00
vicrodh 69029238ee fix(slint): jump-nav natural-top at body-row top, no header overlap
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
2026-05-20 17:09:50 -06:00
vicrodh 06a763443a feat(slint): jump-nav scroll-to-section + hide empty tabs + UX fixes
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
2026-05-20 14:20:47 -06:00
vicrodh 3429fe4ee2 fix(slint): popular tracks header below jumpnav, X inside input
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
2026-05-20 14:03:24 -06:00
vicrodh 6d6a5c0e10 fix(slint): cap search input width; sidebar flush below jumpnav
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
2026-05-20 13:37:43 -06:00
vicrodh 2b5e4aead1 feat(slint): search input slides into the JUMP TO bar inline
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
2026-05-20 13:24:29 -06:00
vicrodh df3ef14c81 fix(slint): release cards show year + quality badge; Load more polished
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
2026-05-20 13:17:48 -06:00
vicrodh 63295583b7 fix(slint): restore Popular Tracks buttons, wire Play All, checkbox left
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
2026-05-20 13:13:56 -06:00
vicrodh ed8bf0054d fix(slint): strip_html iterates by char, not byte (preserves UTF-8)
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
2026-05-20 13:01:56 -06:00
vicrodh ccebf2833c feat(slint): sticky JUMP TO bar, search toggle, View less
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
2026-05-20 12:26:01 -06:00
vicrodh 50b92031cb fix(slint): header spans full width, sticky-style sidebar overlay
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
2026-05-20 12:19:45 -06:00
vicrodh 381ab04ef0 feat(slint): in-page search for the artist view
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
2026-05-20 11:27:11 -06:00
vicrodh 6642b5ef13 feat(slint): release sections render as wrapping grids, not carousels
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
2026-05-20 11:22:38 -06:00
vicrodh 275d0e8121 feat(slint): +1px typography scale and shared paragraph-preserving strip_html
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
2026-05-20 11:13:04 -06:00
vicrodh 669785ae01 feat(slint, core): per-artist play history feeds the discovery filter
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
2026-05-20 11:06:38 -06:00
vicrodh 8b526049bb fix(slint): drop redundant ARTIST label above the artist name
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
2026-05-20 11:02:22 -06:00
vicrodh cccb0c2af0 feat(core, slint): wire MusicBrainz cache for metadata and relationships
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
2026-05-20 10:58:53 -06:00
vicrodh 5c9de3e4d1 fix(slint): sidebar starts below the artist header, always opens
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
2026-05-20 10:56:26 -06:00
vicrodh d68772e3e7 fix(slint): artist bio matches album description; sidebar 1:1 height
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
2026-05-20 10:50:40 -06:00
vicrodh 165674d8a8 feat(slint): you-may-also-like discovery section with sticky dismiss
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
2026-05-20 10:39:53 -06:00
vicrodh 72083adb7f feat(slint): MB relationships section in the network sidebar
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
2026-05-20 10:29:26 -06:00
vicrodh 6d25b25d1d feat(slint): labels and similar artists sections in the sidebar
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
2026-05-20 09:59:02 -06:00
vicrodh d107d4f577 feat(slint): origin section + MB metadata wiring on artist navigate
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
2026-05-20 09:51:22 -06:00
vicrodh 11cdc0d238 fix(slint): open network sidebar by default and inset it vertically
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
2026-05-20 09:27:11 -06:00
vicrodh 5a76196f4e feat(slint): artist network sidebar shell with header
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
2026-05-20 09:16:32 -06:00
vicrodh a8c8947658 feat(slint): network sidebar toggle in artist header
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
2026-05-20 09:11:10 -06:00
vicrodh a768cce5e5 feat(slint): NetworkSidebarState globals and open/closed persistence
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
2026-05-20 09:05:02 -06:00
vicrodh b3acb21c37 feat(slint): bucket artist releases by type and surface bio source
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
2026-05-20 08:21:11 -06:00
vicrodh d01b1c10d8 feat(slint): artist popular tracks - select button + 5+load more
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
2026-05-19 21:48:45 -06:00
vicrodh e116438023 fix(slint): artist view album covers + bio Read-more toggle
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
2026-05-19 21:12:01 -06:00
vicrodh 6d8a2e1fff fix(slint): spinner on home/album/artist + dedupe only in All-tab carousel
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
2026-05-19 21:00:40 -06:00