Commit Graph
369 Commits
Author SHA1 Message Date
vicrodh 3c2f46b756 feat(locallibrary): tag editor remote lookup (MusicBrainz + Discogs)
Complete the tag editor with remote metadata auto-fill.

- qbz-integrations: add frontend-agnostic `discogs` (client + DTOs, Tauri-free)
  and `remote_metadata` (unified DTOs + the pure MusicBrainz/Discogs ->
  RemoteAlbum* converters). Copied from src-tauri so the Slint frontend can do
  remote lookup without depending on the Tauri binary; src-tauri is left fully
  untouched (the production build keeps its own copy — no Tauri-build risk).
- tag_editor.rs: search_remote (provider QbzSelect; MusicBrainzClient::new
  search_releases_extended / DiscogsClient search_releases, generation-guarded),
  select_result, apply_remote (get_release_with_tracks / get_release_metadata ->
  full metadata; overwrites album fields + positional per-track title merge;
  track-count-mismatch + rate-limit toasts), open_in_browser.
- TagEditorModal: a remote-lookup box (provider + Search, a horizontal result
  card strip with select highlight, Apply + open-in-browser). ADR-008: square
  badges, no pills.
- state.slint: RemoteResultItem struct + remote fields/actions on TagEditor*.

LocalLibrary Slint port is now feature-complete (scan/folders, artists images,
tag editor manual+remote, tracks bulk). BUILT BLIND — runtime smoke-test pending.

cc
2026-05-29 15:32:16 -06:00
vicrodh 5a076187e7 feat(locallibrary): Tracks tab — group-by + multi-select + bulk actions
Bring the Tracks tab up to the Favorites precedent.

- Group-by (Off/Album/Artist/Name) with visible album/artist header rows + an
  A-Z jump strip for Name (over the loaded set); show-album hidden under album
  grouping. Group mode persists via a new locallibrary_prefs.rs (favorites_prefs
  precedent; default "off"), loaded in navigate_local_library before the tab
  derives.
- Multi-select toggle (toolbar) + per-row checkbox (TrackRow multi-select-mode)
  + MultiSelectBar with the Tauri Tracks subset: Select all / Add to queue /
  Play next / Add to playlist / Clear (no make-offline, no remove).
- derive_tracks (reduced port: local folder_alpha_key, no genre filter) +
  TRACKS_CURRENT selection-source cache (cloned in apply/append before the move)
  so bulk resolves ids -> LocalTrack in DISPLAY order with no DB round-trip.
- playback::enqueue_local_tracks (source-aware via local_queue_track; reversed
  for play-next); per-row play-next/queue wired in on_track_action.
- Add-to-playlist for local + qobuz_download via PlaylistPickerState.local-mode
  + on_pick i64 branch -> db.add_local_track_to_playlist; Plex rows skipped
  (decided on the underlying LocalTrack.source, not the remapped TrackItem one).
- No-match empty state added.

cc
2026-05-29 15:18:09 -06:00
vicrodh 4e79386daf feat(locallibrary): tag editor modal — manual edit + sidecar/direct save
The local-album tag editor (no remote lookup yet — that lands with the
remote-metadata crate move). Opens from a pencil on the local album detail.

- TagEditorModal.slint: album title/artist + year/genre/catalog + per-track
  title/track#/disc# table (indexed model write-back) + persistence QbzSelect
  (sidecar default / direct opt-in) + save-with-progress. Mounted last in
  AppShell (ADR-009 by mount order). ADR-008: dropdown, no pills.
- tag_editor.rs controller: open_tag_editor (off-thread pre-fetch via
  fetch_album_tracks_blocking + D1 year seed + can-direct-write CUE guard),
  save_tags (validate, is_dir gate, CUE guard, one-time direct-write confirm
  via rfd + library_kv ack, sidecar/direct branch on spawn_blocking with
  per-file progress, update_album_group_metadata via new with_db_mut, post-save
  refresh of the album + browse models), close.
- AlbumPageView: edit pencil CircleAction, visible is-local && source != plex;
  AlbumState gains `source`, set in apply_local_album.
- library_db: with_db_mut for the transactional metadata update.
- main.rs: on_media_action("album","edit") + TagEditorActions wiring (close/
  save/set-persistence with the CUE gate).

cc
2026-05-29 15:03:58 -06:00
vicrodh 7bef91b3d7 feat(locallibrary): Artists rail portraits + capped Qobuz photo fetch
Render artist portraits on the two-column Artists rail and fill missing ones
from Qobuz in the background.

- qbz-library: new get_all_artist_image_urls() bulk reader (custom path else
  cached Qobuz url, keyed by artist_name) so previously-fetched portraits
  reappear on revisit without re-hitting Qobuz. (The Tauri batch command was
  never registered; this is the corrected one-pass reader.)
- state.slint: LocalArtistItem gains a decoded `image` field; LocalLibraryState
  gains the fetch toggle + progress props. merge_artists now returns a Send-safe
  ArtistRow (no slint::Image) across spawn_blocking; apply_artists builds the
  LocalArtistItem with the (default-empty) image on the UI thread.
- ensure_artists_loaded: seeds the rail from get_all_artist_image_urls, spawns
  decode jobs by scheme (local path -> spawn_local_loads, http -> spawn_loads),
  and kicks the capped fetch.
- fetch_missing_artist_images: sequential worker, max 50/session, 1s apart,
  generation-guarded, exact-normalized match only (no wrong-artist persist),
  paints each portrait immediately, persists via cache_artist_image_with_canonical.
- artwork.rs: Copy-safe LocalArtistRowImage { index, gen } target + apply arm
  that resolves index -> name and routes through the new name-keyed dual-setter
  set_artist_row_image (flat master + every grouped section), so portraits
  survive a derive_artists re-run.
- LocalArtistRow renders the decoded image (placeholder until it lands).

cc
2026-05-29 13:31:58 -06:00
vicrodh aa7c320b94 feat(locallibrary): library scan engine + progress (Slice B)
Lift the Tauri scan-orchestration loop into the core crate and drive it from
Settings > Local Library with live progress + cancel.

Core (qbz-library/src/scan.rs): scan_with_progress(db, folder_ids, artwork_cache,
cancel, on_event) replicates v2_library_scan (full) and v2_library_scan_folder
(single) byte-for-byte: CUE files first, per-album sidecar override, embedded ->
folder artwork with a per-folder cache, insert, and missing-file cleanup (whole
DB for full, folder-prefix for single). Progress is pushed through a ScanEvent
callback (no IPC poll) and a cancel AtomicBool is checked at every file boundary
(no cleanup/scan-time on cancel, Tauri parity). Two verified improvements: the
full scan now stamps each folder's last_scan, and network status is re-detected
for every non-overridden scanned folder.

Slint: LibraryScanState global (single poll target; progress computed Rust-side);
a determinate 2-rect progress bar + counts + current-file + Stop button in the
panel; scan-all disabled while running. Controller run_scan() on spawn_blocking
with a ~100ms-coalesced sink (terminal/phase events bypass throttling), an owned
SCAN_CANCEL token, an outcome toast, and a post-scan reload (folder list +
reset browse models so the tabs re-fetch). scan-all/scan-folder/stop-scan wired.

cc
2026-05-29 13:16:21 -06:00
vicrodh 3826298f77 feat(locallibrary): Settings > Local Library — folder management (Slice A)
Port the Tauri folder-management surface to a new Settings > Local Library
sub-section (index 4); the browse-view gear routes here and pre-selects it.

UI:
- LocalLibrarySettings.slint: folder list (icon, display name, disabled/
  unavailable badges, multi-select checkboxes) + toolbar (scan-all/add/edit/
  remove icon buttons) + filter + Maintenance (cleanup missing) + two-step
  Danger Zone clear. ADR-008: plain bordered icon buttons + square badges.
- LibFolderEditModal.slint: alias, enabled, network override + fs-type,
  change-path, accessibility status, last-scanned, scan-this-folder. Mounted
  at the AppShell root (overlays the window, ADR-009 by mount order).
- SettingsView gains the section-4 sub-nav + panel; app.slint re-exports the
  new globals so the Rust layer can populate them.

Controller (local_library_settings.rs): load/add/remove/edit/save/change-path,
cleanup-missing (stats outside the DB lock), two-step clear, filter, select.
All DB access via qbz-library through library_db::with_db on spawn_blocking;
native dir picker + confirms via rfd. Selection preserved across reloads;
network folders get an async 6s-timeout accessibility check (exists() fallback
on timeout, mirroring Tauri).

Core (qbz-library): network_fs_label() for fs-type parity; separator-safe
delete_tracks_in_folder_prefixed() + remove_folder_with_tracks() (fixes the
prefix-collision bug without changing the legacy fn). Scan engine lands in
Slice B.

cc
2026-05-29 13:07:19 -06:00
vicrodh 5986779d85 feat(locallibrary): Artists tab rebuilt as two-column master/detail
Replaces the thin name+counts grid (which was built without a Tauri
review) with the real structure: a two-column master/detail, 1:1 with
the Svelte Artists tab.

Left rail: a merged/deduped, A-Z grouped master list of compact rows
(round avatar + display name + "N albums · M tracks") with a proportional
alpha jump strip and in-place search. The name-merge collapses
normalized-equal spellings into one canonical row (canonical = most
albums, tie by most tracks) and computes accurate unique album counts
from the album set's all_artists, mirroring Tauri's artistMergeResult.
Diacritics are folded best-effort (no unicode-normalization dep).

Right pane: the selected artist's albums, filtered IN PLACE from the
loaded album set by primary/all_artists/multi-credit match (no new
backend call) and rendered via the shared AlbumCollectionView; covers
load through a new LocalArtistAlbumCard artwork target. Clicking an
album opens the local album detail; the play action queues the album.

Custom DB artist images are resolved into the rail model; rendering them
(plus the capped/sequential Qobuz photo fetch) is the remaining image
follow-up — the rail shows the placeholder avatar until then.

cc
2026-05-29 09:40:52 -06:00
vicrodh d676a2f5a4 feat(locallibrary): Folders tab complete — flat toolbar + tree two-pane
Flat mode now has the full toolbar: search, sort (artist/title/year),
group-by (off/letter/artist) and a grid/list view toggle, driven by a
derive_folders() pass that mirrors favorites::derive_albums over the
directory-grouped album set (folders-visible / folders-grouped).

Tree mode is a new two-pane filesystem browser: a flattened FolderNode
list in the left rail (lazy one-level expand via list_folder_children,
collapse drops the descendant block, recursive track counts gate the
chevron) and a detail pane on the right showing the selected folder's
direct tracks plus immediate subfolders, with Play all (recursive) and
per-track play scoped to the folder's direct list. A flat/tree segmented
toggle switches modes; the tree loads its roots lazily on first switch.

Slint has no native TreeView, so the tree is a depth-indented ListView of
visible nodes spliced on expand/collapse. No pills (ADR-008): underline
tabs, small-radius rectangles and an accent selection bar.

cc
2026-05-29 09:17:47 -06:00
vicrodh c36b99b8e8 fix(locallibrary): track-row source indicator (drop fav/offline) + AlbumView favorite item
LocalLibrary track rows (Tracks tab + local album detail) no longer carry the
favorite or offline-cache columns — they don't apply to local content. Instead
they show a subtle per-source indicator (local -> hard-drive tinted; qobuz
offline / plex -> their mark), small + flat rather than the bold album-card
badge (mirrors Tauri's track-row local indicator). TrackItem gains a `source`
field, set by every constructor (Qobuz surfaces -> "qobuz"; map_local_track ->
local/qobuz/plex); TrackRow gains `show-source`.

Also fixes Qobuz AlbumView: the track list had a Favorite column header but the
rows never rendered the heart (show-favorite defaulted false). AlbumPageView
now sets show-favorite + show-download for Qobuz albums (matching the header)
and the source indicator for local albums, with the column header switched to
match each.

cc
2026-05-29 08:43:26 -06:00
vicrodh 6e1009afd0 feat(locallibrary): Artists tab — name + counts grid
Fills the last empty tab: a full-loaded grid of artists (get_artists_with_filter)
with album/track counts, inline circular cards (placeholder icon for now). All
four Local Library tabs (Albums / Tracks / Folders / Artists) now browse. The
navigate match is exhaustive over LibTab. Artist images, click-to-detail, and
name-merge dedup (Tauri's artistMergeResult) are later slices.

cc
2026-05-29 07:44:40 -06:00
vicrodh df351ec6f6 feat(locallibrary): Folders tab (flat) — folder-grouped album grid
Fills the Folders tab: a full-loaded grid of albums grouped by directory
(get_albums_with_full_filter), via the shared AlbumCollectionView (grid +
source badge), covers loaded from disk, loaded once on first visit. Clicking
a folder album opens its detail + plays it — navigate_local_album and
play_local_album now resolve tracks via fetch_album_tracks_blocking, which
tries the metadata grouping (Albums tab) then the folder grouping (Folders
tab), so both album views work. New ArtworkTarget::LocalFolderCard applies
covers to the folders model.

Tree mode (two-pane lazy folder tree) + folder search/sort are later slices.

cc
2026-05-29 07:37:47 -06:00
vicrodh 79551cebb7 feat(locallibrary): local album detail (reuses AlbumPageView)
Clicking a local album (grid card or a track row's album link) opens its
detail in the shared AlbumPageView. navigate_local_album loads the album's
metadata-grouped tracks (get_album_tracks_metadata) + fills folder covers,
populates AlbumState (title / album-artist or "Various Artists" / cover /
quality / "N tracks · duration" / disc-ordered TrackRows) with a new is-local
flag, and resolves the cover from disk. Play routes to local: the media-action
dispatcher gains a guard — when the album view shows a local album, album play
-> play_local_album, a track row -> play the album from that track; Qobuz
album/track play is untouched (guarded by view==Album && is-local).

cc
2026-05-29 07:26:21 -06:00
vicrodh b9b6d141f8 fix(locallibrary): now-playing + queue cover art and current-track sync (local/offline)
Three playback-correctness fixes reported after testing:
- Album/offline cover art now reaches the now-playing bar AND the queue:
  fill_missing_covers fills a track's artwork from the folder cover when the
  index has no artwork_path (the offline-cache writes cover.jpg into the track
  folder but doesn't always backfill the index), applied when building local /
  album play queues. The cover that exists on disk shows, not just on the grid.
- Queue-panel art is source-aware: load_artwork routed every cover through an
  HTTP fetch, so local file:// covers silently failed. It now parses the url
  into an ArtworkRef (Remote vs LocalFile) and decodes local files from disk.
- now-playing/queue current-track mismatch: the poll loop treated ANY track-id
  change while playing as a gapless hand-off and advanced the queue pointer,
  which falsely fired on a manual new-track play and desynced now-playing from
  the queue (erratically, by timing). It now advances only when the new id is
  the expected upcoming track (a real gapless next); Qobuz gapless is unchanged.

cc
2026-05-29 01:24:52 -06:00
vicrodh 460f7659bd fix(locallibrary): align the Tracks scrollbar with the Albums grid (chrome-bezel standard)
The Tracks list scrollbar sat inside the 32px content padding (too far into
the content). Move it to the view's true right edge — the position the Albums
grid already uses, which is the standard for chrome-bezel views: a padded
VerticalLayout holds the ListView while the ListScrollbar floats at the
full-width edge.

cc
2026-05-29 01:24:52 -06:00
vicrodh bcfab8e078 feat(locallibrary): album/list play + source-aware auto-advance (playback Stage 2)
A Local Library album, or the Tracks list, now plays through with auto-advance
across mixed local/offline sources.

The one change to the shared playback path is additive and provably safe for
Qobuz: play_audible gains a guard at the top — when the CURRENT queue track's
source is "local" (and its id matches), it plays the file from disk via
play_local_file_audible (read -> player.play_data + CUE seek); otherwise it
falls through to the EXISTING offline/Qobuz tier-walk, byte-for-byte unchanged.
Qobuz/offline queue tracks never carry source "local", so their behavior cannot
change. Auto-advance, next/previous and play-all all route through play_audible,
so they become source-aware for free. current_track() releases the queue read
lock before play (no nested lock / deadlock).

- qbz-player: QueueManager::current() (additive).
- qbz-core: QbzCore::current_track() (additive).
- qbz-slint playback.rs: source-aware play_audible + play_local_file_audible
  (extracted) + play_local_tracks_now + play_local_album
  (get_album_tracks_metadata) + play_local_tracks_from (search_with_filter);
  replaces the Stage-1 single-track helper.
- main.rs: album card "play" -> play the album; Tracks row "play" -> play from
  that row down the searched list. (play-next / queue still stubbed.)

Built green (cargo check). Local-file gapless isn't wired (local tracks aren't
prefetched, so local/mixed transitions use the simple track-ended -> next path);
Qobuz gapless is unchanged. Runtime smoke-test pending: (1) Qobuz streaming
still plays + advances, (2) an offline album advances, (3) a local album/list
advances.

cc
2026-05-29 00:50:40 -06:00
vicrodh 5349c947a6 feat(locallibrary): play a track from the Tracks tab (local + offline)
First source-aware playback stage. Clicking a track in the Local Library
Tracks tab now plays it, mirroring Tauri's v2_library_play_track routing:
- offline-cached (qobuz_download) tracks reuse the shared tier-walk
  (after_track_change -> play_track_resolved -> offline resolve -> play_data),
  keyed by the Qobuz id;
- local user files are read from disk and handed to the player's play_data
  seam (which extracts the sample rate + drives the PROTECTED device init,
  untouched), with a CUE seek for virtual tracks.

Additive: play_local_library_track + local_queue_track (mirrors Tauri's
local_track_to_queue_track) are new; the shared Qobuz play_audible path is
unchanged (no regression risk to streaming). Single-track play (a 1-track
queue) — album/queue + auto-advance need a source-aware advancement handler
and land in the next stage. Built green (cargo check -p qbz-slint);
runtime smoke-test pending (a local FLAC + an offline track should play).

cc
2026-05-29 00:37:40 -06:00
vicrodh 56b3ca790b feat(locallibrary): Tracks tab — server-paginated flat list
Adds the Tracks browse tab. Each page is a search_with_filter_page query
(new paged variant of search_with_filter) appended on scroll-near-bottom, so
the list never materializes the whole table — the documented ~16K-row Tracks
freeze. Reuses the shared TrackRow in a virtualized ListView + ListScrollbar
(the Favorites Tracks pattern) and the Albums controller's generation-guard
(TRACKS_GEN) so a search/retry can't be clobbered by a stale in-flight page.

- qbz-library: search_with_filter_page(query, offset, limit, include_qobuz,
  exclude_network) — same ordering as search_with_filter, LIMIT/OFFSET.
- local_library.rs: map_local_track (LocalTrack is Send -> crosses
  spawn_blocking; TrackItem built on the UI thread), fetch_tracks_page,
  apply/append, reload/ensure/load_more (TRACKS_GEN-guarded).
- LocalLibraryView.slint: Tracks body (ListView + TrackRow, show-artwork off
  per Tauri's perf default, show-favorite/show-download off) + a search
  toolbar + loading/error/empty states.
- state.slint + main.rs: tracks state/actions + lazy load on first visit +
  search/load-more/retry wiring.

Group-by, multi-select/bulk, and per-row playback land with the source-aware
playback slice (track-action is a stub, like Albums). Built green
(cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-29 00:17:51 -06:00
vicrodh 3fc724af84 fix(locallibrary): show offline/folder album covers + add source badge
Two fixes from the first Albums-grid test pass.

Offline/folder covers (the reported "cover exists in the folder but isn't
shown"): some albums — offline-cached CMAF bundles and folder downloads —
have no artwork_path in the index even though a cover.jpg sits in the track
folder (older offline-cache downloads didn't backfill it). Add a frontend-
agnostic LibraryDatabase::resolve_album_cover_fallback(group_key) that finds
a representative track, derives its folder (the path itself for a CMAF bundle
dir, else the parent), and returns <folder>/cover.jpg when it exists. The
Slint Albums mapper calls it whenever the album has no artwork. Verified
against the live index: it resolves the folder covers that exist on disk
(FLAC-layout + CMAF-with-cover). Older CMAF bundles that never downloaded a
cover (segments only) still show the placeholder — those need a cover
re-fetch (offline-cache maintenance), tracked separately.

Source badge: the Local Library album card now shows the always-visible
source badge inside the artwork (bottom-right) — a hard-drive glyph for local
files, the Qobuz logo for offline copies, the Plex logo for Plex. Added
AlbumCard.show-source-badge (default off; threaded through AlbumCollectionView
/AlbumGrid) + AlbumCard.source; album_map now carries the real source and
map_local_album maps user->local / qobuz_download / plex. Small rounded
square, not a pill (ADR-008). Logos copied into the Slint icon assets.

Built green (cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-28 23:40:58 -06:00
vicrodh 835a6cad85 feat(locallibrary): Albums tab — server-paginated chunked grid
First Local Library data slice. The Albums tab browses the metadata-grouped
local albums via the performant server-paginated path (the same query Tauri's
chunked store uses), reusing the shared AlbumCollectionView grid — no new
rendering. Scales past the documented ~16K-row Tracks freeze by never
materializing the full set.

- local_library.rs: map_local_album (LocalAlbum -> the plain album_map
  AlbumCard, Send-safe; the non-Send slint::Image conversion via to_item
  happens on the UI thread), local_quality (Hz -> kHz), sort_params; a
  blocking fetch_albums_page over LibraryDatabase::get_albums_metadata_page
  (spawn_blocking); apply/append + reload/ensure/load_more driven by an
  ALBUMS_GEN generation guard so a search/sort/retry can't be clobbered by a
  stale in-flight page.
- LocalLibraryView.slint: Albums body reusing AlbumCollectionView (grid,
  show-favorite: false), a search + sort toolbar (both server-side), auto
  load-more on scroll-near-bottom (DiscoverBrowse pattern), and loading /
  error+retry / empty states.
- Local covers: ArtworkTarget::LocalAlbumCard + spawn_local_loads resolve the
  on-disk cover path through the source-aware ArtworkRef::LocalFile pipeline;
  the target carries the generation so a cover decoded after a reload is
  dropped instead of landing on a replaced row.
- state.slint: LocalLibraryState album fields + LocalLibraryActions callbacks.
- main.rs: navigate_local_library lazily loads page 1 on first visit; the
  on_albums_* callbacks wire search/sort/load-more/retry (open-album /
  open-artist / album-action are TODO no-ops until the detail + playback slices).

List/group/A-Z/quality filters, album detail, and playback are later slices.
include_qobuz_downloads mirrors the show_in_library setting default (true).
Built green (cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-28 22:44:18 -06:00
vicrodh 6974721b7a feat(albumcard): add show-favorite flag to hide the heart in non-favoritable contexts
The shared AlbumCard always rendered the favorite heart (overlay button +
"Add to favorites" menu item). Local Library albums are not Qobuz-favoritable
(Tauri's AlbumCardLibraryLite hides favorite/genre), so the upcoming local
grid needs to suppress it.

Add an additive `show-favorite: bool` (default true) on AlbumCard, threaded
through AlbumCollectionView -> AlbumGrid. The fav overlay button uses
`visible:` (not an `if`) so it stays referenceable by `overlay-on` (keeping
the Qobuz hover-no-flicker behavior intact); the menu item is gated with an
`if`. All existing callers (Discover / Favorites / Label) default to true,
so behavior is unchanged.

cc
2026-05-28 22:44:04 -06:00
vicrodh 1dfc22dd37 fix(locallibrary): rebuild view chrome on the mandatory fixed-chrome nav header
The nav-scaffolding view (c50ae171) invented its own chrome: a title+stats
header with no NavButtons (so no back/forward history nav), plus a tab bar
of border-radius:18px pill chips — an ADR-008 violation. Rebuild it 1:1 on
the app's mandatory fixed-chrome header (the same FavoritesView/Discover
use):

- Row 1: NavButtons (back/forward) + "Local Library" title (left), the tab
  menu floated right. Tabs are underline tabs (LibTab/LibTabMenu mirroring
  FavTab/FavTabMenu) — no pills.
- The settings gear becomes a standalone icon-only circle matching
  NavButtons (the one circular shape ADR-008 permits), routing to
  Settings > Local Library.
- Row 2 (per-tab actions + toolbar) is established with vertical-stretch:0
  so it doesn't inflate while empty; filled per slice (Albums next).
- 1px divider, then a single scrolling content area with a placeholder.

Wiring (select-tab / open-settings, nav, mount) is unchanged — only the
.slint chrome was rebuilt.

cc
2026-05-28 21:48:06 -06:00
vicrodh c50ae17102 feat(locallibrary): nav scaffolding for the Local Library Slint view
Greenfield Local Library port, slice 1 — the view now mounts and navigates
end to end (empty placeholder body; per-tab data lands next):

- state.slint: `ContentView::local-library` + `LocalLibraryState`
  (active-tab / loading / load-error / per-category counts) +
  `LocalLibraryActions` (select-tab, open-settings). Re-exported from
  app.slint so the globals reach the Rust API.
- ui/locallibrary/LocalLibraryView.slint: header (title + stats + gear) +
  4-tab bar (Albums / Artists / Folders / Tracks) + placeholder body.
- shell: AppShell mounts the view on `local-library`; HeaderBar's
  Local Library menu items now carry routes (local-albums / -artists /
  -folders / -tracks) instead of being inert.
- nav.rs: `NavEntry::LocalLibrary { tab }` — per-tab history entry,
  mirroring Favorites / Discover (back/forward moves between tabs).
- src/local_library.rs: `LibTab` controller stub (route/tab-id parsing,
  round-trip tested).
- main.rs: apply_entry arm + `navigate_local_library` + header-route
  branch + in-view tab-bar (select-tab) and gear (open-settings ->
  Settings; management lives under Settings > Local Library) wiring.

qbz-slint cargo check green. Built blind — needs a runtime smoke test
(./scripts/slint-dev.sh): click the Local Library header menu, confirm the
view shows and the 4 tabs switch.

cc
2026-05-28 17:51:52 -06:00
vicrodh 827ba9d77a feat(playback): source-aware artwork resolution (ArtworkRef + PlaybackSource)
Add the frontend-agnostic contract for the source-aware playback context:

- qbz-models: new `source` module with `PlaybackSource` (Qobuz / OfflineCache
  / Local / Plex; parses the existing stringly-typed `source` field, defaults
  to Qobuz) and `ArtworkRef` (Remote / LocalFile / PlexThumb / Embedded /
  None). `QueueTrack::source_kind()` + `QueueTrack::artwork_ref()` derive both
  without changing the wire format. `PlaybackSource::is_qobuz_streamable()`
  seeds the Qobuz Connect queue gate (only Qobuz-sourced tracks are castable).

- qbz-slint artwork pipeline: `fetch_and_decode_ref(&ArtworkRef, ...)` resolves
  cover bytes per origin — HTTP/Plex through the disk cache, local files read
  directly, embedded bytes in place. This fixes the root cause of local/Plex
  artwork never reaching the UI (the loader was HTTP-only). The URL-only
  `fetch_and_decode` becomes a thin wrapper, so all existing card/row jobs are
  unchanged. The now-playing loader now takes an `ArtworkRef` derived from the
  queue track, so local-library/Plex covers reach the now-playing bar.

Groundwork for the Local Library Slint port (source-aware playback context
designed in from the start). qbz-models tests pass; qbz-slint cargo check green.

cc
2026-05-28 16:36:02 -06:00
vicrodh 78b1a9c508 refactor(slint): Settings "Offline" panel matches the other sections' layout
The panel improvised its own title + free-form paragraph + ghost buttons,
breaking the established Settings structure. Rebuilt it like Audio/Playback/
Appearance: a GroupHeader ("OFFLINE CACHE") + SettingRow rows (label +
description + a SecondaryButton control on the right) — Manage offline cache /
Cache folder / Clear offline cache. No panel-level title (the left sub-nav
names the section). Keeps the layout consistent for the offline settings we'll
add here later.

cc
2026-05-28 13:18:03 -06:00
vicrodh 2b6c71000f feat(slint): offline manager — checkboxes + bulk, status to the end, track numbers
Per user feedback:
- Multi-select: a checkbox on every track row + "Select all"/"Clear" toggle +
  a bulk bar ("N selected" / Re-download / Remove). Selection is an in-place
  model edit (offline_manager::toggle_select / set_all_selected) with a live
  selected-count; bulk reads the checked ids and runs redownload_track /
  remove_cached per track.
- Moved the status glyph to the END of the row (albums and tracks) — it read
  as "missing art" at the leading edge under the album-grouping model.
- Track rows now show their position number in the leading slot (filling what
  looked empty). (Position within the album; the catalog track number isn't in
  the cache index.db — a follow-up if the real number is wanted.)

qbz-slint compiles clean.

cc
2026-05-28 12:23:46 -06:00
vicrodh db3d8491f5 feat(slint): offline manager 1:1 — covers, artist rail, sort, failed-only, persisted limit
Closes the v1 gaps in the offline manager toward Tauri parity:

- Album COVERS: rows load the on-disk cover.jpg (per album). Built Send-safely
  — the worker passes the cover PATH (string), the image is decoded on the UI
  thread (slint::Image is not Send).
- Two-pane layout with an A-Z ARTIST RAIL (left, "All artists" + per-artist
  "N albums · M tracks", selectable) + the discography list (right).
- SORT dropdown (A-Z / Recent / Largest / Smallest; Recent uses the DB's
  last_accessed_at DESC order) and a "Failed only" toggle. Filter state lives
  Rust-side (offline_manager) so rebuild can read it off-thread; mirrored back
  onto OfflineManagerState for the UI.
- Limit now PERSISTS to disk (<audio>/offline_limit), restored on activate via
  apply_persisted_limit; the manager's edit-limit writes it.
- Manager mutations (remove/redownload/clear) now rebuild on completion
  (offline_manager::rebuild made pub, called at the end of each cache op) —
  the 500ms timed reload hack is gone.

Deferred (user-accepted): the "track no longer available on Qobuz" case —
offline copy still plays, but warn it can't be re-downloaded / won't work on
Qobuz Connect.

qbz-slint compiles clean.

cc
2026-05-28 11:52:55 -06:00
vicrodh 3e0a718e0c feat(slint): offline cache manager + remove/redownload + Settings Offline page
Slices 3 + 4 of the offline-cache port.

Slice 3 (controller, offline_cache.rs):
- remove_album (maintenance::remove_album_cached_tracks + library rows + live
  row update), redownload_track / redownload_album(failed_only) (via
  select_redownload_targets + reset_track_for_redownload + spawn), clear_all
  (purge), open_folder (xdg-open / open the cache dir).

Slice 4 (manager + Settings):
- OfflineManagerView.slint: a standalone route (NavEntry::OfflineManager ->
  ContentView.offline-manager, mounted in AppShell, mirrors PlaylistManager).
  Stats bar (tracks count / size / limit / usage bar + edit-limit GB input +
  open-folder + clear-all) over a scrolling album-header + track-row list with
  a spinning/check/alert status glyph and per-row re-download / remove.
- offline_manager.rs: builds the album->track rollup + stats from index.db
  (get_all_tracks), pushes [OfflineRow] + stats to OfflineManagerState; set_limit
  (in-memory, session-scoped) + reload_soon (refresh after a mutation).
- OfflineManagerActions wired in main.rs (open/refresh/remove/redownload/
  set-limit/clear-all/open-folder/play).
- Settings gains an "Offline" section (OfflineSettings.slint): Manage offline
  cache (-> the manager), Open cache folder, Clear all. The Local-Library
  rows (show-in-library, repair) arrive with the Local Library port.

qbz-slint compiles clean.

cc
2026-05-28 11:23:55 -06:00
vicrodh a7f2283c92 feat(slint): offline button on every Qobuz track row + cache-whole-playlist
- TrackRow show-download now defaults to true: every Qobuz track row (album,
  artist, label, playlist, mix, search, favorites) carries the offline
  cloud/check button. Non-Qobuz surfaces (future Local Library / Plex) opt out
  by setting it false.
- Playlist header gains a "make offline" CircleAction -> ("playlist","cache")
  -> offline_cache::cache_playlist (get_playlist -> batch its tracks), mirroring
  the album-header action.

qbz-slint compiles clean.

cc
2026-05-28 10:29:43 -06:00
vicrodh 6572d6f33f feat(slint): album-header "Make album offline" + direct button on favorites rows
- offline_cache::cache_album fetches an album's tracks and batches them; wired
  to a new ("album","cache") media-action arm + a cloud-download CircleAction in
  the AlbumPageView header action row.
- Favorites track rows now enable show-download, so the direct per-row offline
  button shows there too (not just album rows / cached tracks).

Rounds out Slice 2: trigger surfaces (per-row button on album+favorites, context
menu everywhere, bulk multi-select, album header), live status (spinner/check/
alert) + seeding, and the unlock padlock. qbz-slint compiles clean.

cc
2026-05-28 09:29:20 -06:00
vicrodh 6bea6a7a0a feat(slint): seed per-row offline status from the index.db on load
A cached track now shows its "ready" check when a view is built, persisting
across navigation within the session (was: only live downloads showed state).

- offline_cache.rs: a session-wide CACHED_IDS set (mirrors fav_cache), seeded
  from index.db ready rows via load_cached_ids() on login/restore, kept in
  sync as downloads complete (mark_cached true) and copies are removed (false).
  is_cached(id) reads it.
- The 7 TrackItem builders seed cache-status from is_cached (favorites/search
  clone/precompute the id to avoid moving it before the borrow).
- auth.rs calls load_cached_ids after the offline cache activates.

qbz-slint compiles clean.

cc
2026-05-28 09:01:06 -06:00
vicrodh 43c1599400 fix(slint): spin the downloading loader (animation-tick rotation)
The queued/downloading state showed a static loader-circle. Split it into its
own `if` arm that continuously rotates via animation-tick (same technique as
LoadingSpinner), isolated so the per-frame tick only drives a row while it is
actually downloading. The idle/ready/failed glyph stays static. Communicates
that the backend is working.

cc
2026-05-28 08:36:40 -06:00
vicrodh 3ffdca729d feat(slint): bulk "Make available offline" in the Library multi-select bar
Adds the 6th MultiSelectBar action -> offline_cache::cache_tracks(selected),
mirroring Tauri's bulk caching. Clears the selection after queuing. (Also
retires the cache_tracks dead-code warning.)

cc
2026-05-28 08:28:47 -06:00
vicrodh d5f23c38d8 feat(slint): per-row offline affordance + the unlock padlock (Slice 2 UI)
TrackRow now renders the offline state the controller maintains:

- The offline button is stateful: cloud-download (none, click -> cache) ->
  loader-circle accent (queued/downloading) -> circle-check accent (ready,
  click -> uncache) -> triangle-alert red (failed, click -> retry). Shown on
  surfaces that enable it AND on any row with active cache state.
- The unlock PADLOCK (user-requested, back from Tauri): a pulsing lock glyph
  overlays the play cell while the track's CMAF bundle decrypts, driven by the
  `unlocking` flag the row_sink toggles on UnlockStart/UnlockEnd. New lucide
  lock.svg / lock-open.svg assets.

The context menu's "Make available offline" already routes to caching. Testable
loop: cache a track (album button or context menu) -> watch it download -> play
it offline -> padlock pulses during decrypt. qbz-slint compiles clean.

cc
2026-05-27 22:05:37 -06:00
vicrodh b65b402ff1 feat(slint): offline-cache controller + per-row status/unlock plumbing (Slice 2)
The Rust half of the trigger/status UI:

- offline_cache.rs: cache_track / cache_tracks / remove_cached + row_sink — a
  CacheEventSink that reflects download status (queued/downloading%/ready/failed)
  and the unlock padlock onto every visible row, plus terminal toasts. cache_*
  pre-flight the limit, insert the queued row, and spawn the shared
  spawn_track_cache_download with the core client.
- TrackItem gains cache-status / cache-progress / unlocking; set_row_cache_status
  + set_row_unlocking mirror set_row_favorite (walk the 6 track models + search
  hero). All 7 exhaustive initializers updated.
- media-action arms ("track","cache"|"download"|"uncache") dispatch to the
  controller; the play path now passes row_sink so UnlockStart/End fire the
  padlock state during CMAF decrypt.

Compiles clean. Next: the TrackRow.slint affordance (make-offline button + ring
/ check / warn) and the padlock glyph that render these fields.

cc
2026-05-27 21:45:26 -06:00
vicrodh 1413cec626 feat(slint): wire offline cache into the play path (Slice 1 Slint side)
- offline.rs: constructs + owns a per-user OfflineCacheState (same index.db +
  library.db paths as Tauri, so the offline store is shared across frontends);
  activated on login/restore, torn down on logout (hooks in auth.rs).
- playback.rs: play_audible + the gapless trigger now route through QbzCore's
  play_track_resolved / fetch_for_gapless_resolved, so an offline-cached track
  resolves from disk (decrypt -> play_data) before hitting the network. The
  unlock-animation sink is None for now (padlock lands with the Slice 2 UI).

qbz-slint compiles clean. Offline PLAYBACK now works in Slint; the "Make
offline" trigger UI + per-row status + the padlock are next (Slice 2).

cc
2026-05-27 21:22:16 -06:00
vicrodh 81e7d21868 feat(slint): A-Z alpha index for the Artists sidepanel left list
The sidepanel left artist list is now always A-Z grouped (derive_artists
groups when group-enabled OR view-mode==sidepanel; the view toggle re-derives)
with letter headers, and an AlphaStrip on its right edge jumps the list
Flickable proportionally (same approach as the album grouping — not exact,
by design). Replaces the flat ListView.

cc
2026-05-27 16:19:12 -06:00
vicrodh 0b12d102ce feat(slint): favorites Artists sidepanel UI (two-column browse)
Add the grid<->sidepanel view toggle to the Artists toolbar (group select is
now grid-only, matching Tauri) and the sidepanel two-column layout: a
self-scrolling left list of SidepanelArtistRows (round thumb + name, selected
highlight) that calls select-artist, and a right column with the 5-state
machine (select-hint / loading / error / no-albums / sections). Each release
section renders its title + a flat AlbumCollectionView grid; a footer links to
the full artist page. The other-flick branch now excludes the sidepanel so the
two columns scroll independently.

cc
2026-05-27 16:08:45 -06:00
vicrodh 832f257e5a feat(slint): favorites Artists sidepanel controller (select-artist + view toggle)
Wire the sidepanel backend ahead of its UI: select-artist loads the chosen
artist via artist::load_artist (reusing the /artist/page release_type
classifier per the user's decision) and apply_selected_artist maps its
release sections into selected-artist-sections (DiscoverSections) + total;
a FavoriteArtistAlbum artwork target fills the section covers. The
artists-set-view handler flips grid<->sidepanel (persisted as artists_view)
and clears the selection on the way back to grid. card_to_item is now
pub(crate) for reuse.

cc
2026-05-27 15:40:18 -06:00
vicrodh 02b63d2cd2 refactor(slint): rename the Favorites nav-toolbar setting label to Library
Qobuz parity with the renamed favorites header; avoids leaving the old
"Favorites" wording behind (will be revisited during the settings wiring).

cc
2026-05-27 15:32:15 -06:00
vicrodh 41692012ee feat(slint): favorites Artists A-Z grouping + alpha strip
Add the optional A-Z group toggle to the Artists grid (Group: Off/A-Z
QbzSelect, default Off, persisted via favorites_prefs artists_group).
derive_artists now buckets the filtered set into FavArtistSection letter
groups (# first, then A-Z) and builds the alpha-jump model; the grouped grid
renders per-letter headers + sub-grids and an AlphaStrip on the right that
jumps the Flickable proportionally (mirrors the Albums alpha grouping).
Also renames the favorites view header title "Favorites" -> "Library" to
match Qobuz.

cc
2026-05-27 15:32:15 -06:00
vicrodh da683cd5d0 fix(slint): favorites sub-header (row 2) inflating on artists/playlists/labels
The per-view action+toolbar row is a HorizontalLayout whose sized children
are conditional per tab. When they are absent its auto vertical-stretch
becomes 1, so it split the leftover height with the scrolling content. The
Flickable-based tabs (artists/playlists/labels) have a small content
preferred height, leaving slack the row grabbed -> a giant empty sub-header.
Tracks/Albums looked fine only because their content fills. Pin the row to
vertical-stretch: 0 so the content always absorbs the slack.

cc
2026-05-27 15:10:01 -06:00
vicrodh bc95b30db3 feat(slint): favorites Artists header Shuffle (random artist)
Add a Shuffle CircleAction to the Artists row-2 action cluster that opens a
random artist from the visible set (random ARTIST, not a random album —
matches Tauri). Adds favorites::random_visible_artist + the artists-shuffle
handler routing through invoke_open_artist.

cc
2026-05-27 14:58:55 -06:00
vicrodh b87489206a feat(slint): favorites Artists search + render the filtered set
Point the artists grid at artists-visible (the search-filtered model) and
add the "No artists match your search" empty state. Add the Artists row-2
toolbar branch with the shared ExpandableSearch wired to artists-search +
search-artists -> derive_artists.

cc
2026-05-27 14:41:25 -06:00
vicrodh 70083cf33d feat(slint): favorites Artists grid card fidelity + derive_artists
Drop the albums_count line from the artist card (Tauri #169: Qobuz's count
includes compilations/tributes and is misleadingly high) — removed from
ArtistCard, map_artist, FavoriteArtistItem and FavArtistCard. Shrink the card
image to a 120px circle to match Tauri. Add derive_artists (name-substring
search filter → artists-visible/artists-shown), seeded on load; grouping +
the alpha strip layer on next.

cc
2026-05-27 14:35:49 -06:00
vicrodh 210808fd39 feat(slint): scaffold favorites Artists state + actions
Add the FavoritesState fields for the Artists port: the grid toolbar
(artists-search / artists-view-mode grid|sidepanel / artists-group-enabled),
the search-filtered + A-Z-grouped models (artists-visible / artists-grouped
via the new FavArtistSection struct / artists-alpha / artists-shown), and
the sidepanel selection state (selected-artist-id/name, loading/error,
selected-artist-sections as DiscoverSections + total). Add the FavoritesActions
callbacks (search-artists, artists-set-view, artists-set-group, artists-shuffle,
select-artist). Controller + view follow.

cc
2026-05-27 14:28:32 -06:00
vicrodh a75911b09b feat(slint): favorites Playlists grid/list toggle + List view (persisted)
Add the user-requested grid/list switch to the Playlists toolbar
(parameterized the shared ViewToggle so albums + playlists both drive it)
and a new PlaylistListRow (collage thumb + name + owner·tracks + play +
overflow menu: play/play-next/queue/favorite), bound to playlists-view-mode.
The view mode persists via favorites_prefs (playlists_view). Tauri ships
grid only — the list view is a labeled Slint-better addition.

cc
2026-05-27 14:21:27 -06:00
vicrodh 120670147c feat(slint): favorites Playlists actions — play/queue/share/favorite + sub-tab/search
Wire the playlist card media-actions: play / play-next / queue fetch the
playlist's tracks (core get_playlist) and route to playback play/enqueue;
share copies the Qobuz web-player playlist URL to the clipboard; favorite
toggles the LOCAL QBZ favorite (Library un-favorites in place and drops the
row, Following adds to Library — per user decision). Wire the sub-tab switch
and the name|owner search to derive_playlists. Add share::qobuz_playlist_url
and favorites::remove_playlist_row.

cc
2026-05-27 14:11:31 -06:00
vicrodh 116bc13660 feat(slint): favorites Playlists view — sub-tabs + rich card grid
Swap the passive single-cover FavPlaylistCard tile for the shared
discover/PlaylistCard (collage + hover overlay play/fav/more + context
menu) over playlists-visible, and add the Library/Following underline
sub-tab selector (no pills, ADR-008) above the grid with per-sub-tab and
search-empty states. Extend the shared PlaylistCard with a body-opens
prop so a favorites card-body click opens the playlist while the play
button plays (discover/search keep body=play, unchanged). Add the
Playlists search box to the row-2 toolbar. Remove the dead
FavoritePlaylistItem struct + playlists field + FavPlaylistCard.

cc
2026-05-27 14:11:31 -06:00
vicrodh 2d2f65a1cb feat(slint): favorites Playlists two-source load (Library + Following)
Replace the single merged get_user_playlists load with the two real
sources mirroring Tauri: Following = playlists the user follows but does
not own (get_user_playlists filtered owner.id != current_user_id);
Library = locally-favorited ids (SQLite get_favorite_playlist_ids) in
favorited order, intersected with the fetched set and falling back to a
single get_playlist for ids not present. Both map to the rich
SearchPlaylistItem (4-cover collage) via search::map_playlist/playlist_item,
replacing the single-cover PlaylistCard. derive_playlists picks the active
sub-tab source and applies the name|owner search filter; the 4-cover
artwork pipeline (FavPlaylistCover{following,index,slot}) fills the collage
and propagates into the search-filtered visible model. The tab badge now
counts the local Library set. Adds library_db::current_user_id.

cc
2026-05-27 13:43:12 -06:00
vicrodh bfb3c9b9b5 feat(slint): scaffold favorites Playlists two-sub-tab state + actions
Add the FavoritesState fields for the Playlists port: two SearchPlaylistItem
sources (playlists-favorites = local Library, playlists-following = Qobuz
followed) + playlists-visible (current sub-tab after search), the transient
sub-tab/search/view-mode strings, and the following-count badge. Add the
FavoritesActions callbacks (playlists-set-sub-tab, search-playlists,
playlists-set-view, playlist-action). Wiring + controller follow.

cc
2026-05-27 13:32:53 -06:00