Commit Graph
273 Commits
Author SHA1 Message Date
vicrodh ec2a52707f feat(myqbz): Slint collection/mixtape detail view (read-only)
Phase-2 Slice 3. Opening a grid card navigates to the detail view:
- NavEntry::MixtapeDetail(id) + back/forward + scroll-restore; nav-in
  un-stubs the grid card click.
- src/myqbz_detail.rs: get_collection via the shared crate (hydrated items),
  load/apply/refresh deriving the render model (filter -> search -> sort).
- ui/myqbz/MixtapeDetailView.slint: hero (CollectionMosaic/custom cover, title,
  kind eyebrow, meta), sticky toolbar (search/sort/type+source filter/reset/
  select/view-mode), the item list in list + grid modes -- exact 8-col grid
  (40 1fr 140 80 160 72 60 40), 56px rows, per item_type, source/quality cells.
  Empty/loading/not-found states.

Read-only boundary: hero CTAs + per-row play/menu actions are logging stubs;
expanded-mode track expansion, in-detail modals, DJ-mix, bulk bar, and live
resolveItems (quality/plex source) are deferred. KNOWN DEVIATION: the hero
action row shows flat circles; the spec-3.6 layout (Play/Shuffle/DJ-mix +
MoreHorizontal overflow menu) is folded into Slice 7 (its overflow items are
the edit actions). Backend crates untouched. cargo check -p qbz-slint green.

cc
2026-06-09 03:43:56 -06:00
vicrodh c0d980e9c7 fix(myqbz): replicate the empty-state flash during load (1:1)
Drop the `&& !MyQbzState.loading` gate on the zero-items empty states so
"No mixtapes yet" / "No collections yet" shows during load instead of a
blank, matching the Tauri/Svelte behavior (empty array -> empty state
flashes until the load resolves). Per user decision (DQ: replicate).

cc
2026-06-09 03:06:12 -06:00
vicrodh 698fb2fa7f feat(myqbz): Slint My QBZ sidebar nav + read-only Mixtapes/Collections grids
Phase-2 Slice 2 of the MyQBZ port. Wires the mixtape DB + the first
visually-reachable surface in Slint (read-only):

- Migrations: run qbz_mixtape::schema::run_mixtape_migrations in enter_shell
  (via library_db with_db -> with_connection), best-effort, on session open —
  same per-user library.db the Tauri build migrates.
- Glue: new src/myqbz.rs mirrors playlist_manager.rs — list_collections via the
  shared crate (items hydrated), model/sort/filter builders, mosaic artwork jobs,
  navigate. NavEntry::Mixtapes/Collections + back/forward + scroll-restore scopes.
- UI: ui/myqbz/{MixtapesView,CollectionsView,CollectionMosaic,MyQbzShared}.slint,
  1:1 with specs 10/11 (208px grid, mosaic custom->2x2/3x3->kind-glyph, toolbar,
  both empty states, ICU plural). MixtapeCardItem + MyQbzState/MyQbzActions in
  state.slint; mounted in AppShell. My QBZ sidebar entry routed to the two grids
  (branded mask-tinted icons), matching the port's existing flyout-nav idiom.

Read-only boundary: create/open-detail are logging stubs; titlebar My QBZ
dropdown and the Settings>Appearance rename (DQ3) are deferred to later slices.
Backend crates untouched. cargo check -p qbz-slint green.

cc
2026-06-09 02:49:44 -06:00
vicrodh f508758d32 feat(slint): Local Library folder management — compact table + per-folder sync/delete
- Replace the folder list with a compact table (sticky header: FOLDER / LAST SCAN /
  STATUS + actions, internal scroll).
- Per-row sync (scans only that folder) and per-row delete (removes the DB entry +
  indexed tracks, never the files); delete no longer relies on rfd/zenity (which
  fails-closed on zenity-less systems) — direct removal + a toast.
- Fix the row event order so the per-row buttons receive their clicks instead of
  toggling selection.
- Fix mis-centered button glyphs (Stop, change-path, scan, tag-editor search): an
  inner layout filled its parent so the centering collapsed to x:0 — use explicit
  padding.

cc
2026-06-09 01:44:48 -06:00
vicrodh 41c5261613 feat(slint): local album homologation, Recently Played fixes, local artist nav
Album view (local/Plex):
- Rebuild LocalAlbumView as a near copy of the Qobuz AlbumPageView (whole-page
  scroll, 224px cover, action row, toolbar with quality badge + track search,
  column header, source-aware rows) minus the label/awards sidebar and Qobuz
  context menus. Add a per-album client-side track search.

Recently Played (Home) — D:
- Carry the track source (qobuz|plex|local) through the recently-played store so
  the cards resolve source-aware artwork (PlexThumb / local file), the album-card
  play routes to local/Plex playback instead of the empty Qobuz path, and the
  card click opens the right album view.
- Fix a regression from the Plex version split: local_queue_track now derives the
  content-hash plex_album_key for album_id (navigation) while album_group_key keeps
  the per-edition split key, so "go to album" and recent Plex albums open correctly.

Local artist navigation — B:
- Implement open_artist (was a TODO): latch the requested artist and select it once
  the Artists tab loads. The shared open-artist is now source-aware (numeric id ->
  Qobuz artist; name -> LocalLibrary Artists tab). Wire the LocalAlbum header artist
  link and the now-playing "Go to artist" for Plex/local.

Folder delete -> Recently Played cascade:
- Removing a Local Library folder now also prunes its albums/tracks from the
  recently-played store (album_keys_in_folder + recently::prune_albums).

Covers — C:
- Robust on-disk folder cover lookup (cover/folder/front/art/<album>.jpg, any image
  as a last resort), shared by the Folders subcards and now-playing/album rows.

cc
2026-06-09 01:44:39 -06:00
vicrodh 46673b2077 feat(slint): extend scroll-position restore to all views
Wire the per-history-entry scroll memory (added for Local Library) into
every top-level view: Home/Discover, Album, Local Album, Artist, Musician,
Label, Label Releases, Location, Favorites, Mix, Playlist, Playlist Manager,
Offline Manager, Search, Settings.

Each view's primary scroll container reports its viewport-y and restores it
on back/forward via NavState.restore-scope (scope_for in main.rs maps each
entry to a stable id; tab views carry the tab, e.g. "fav:albums"). The
container restores once per arm, only after content lays out, then clears
the scope. A `changed sr-armed` trigger covers views that persist across
sub-tab navigation (Home's Discover tabs, Favorites' shared list, Playlist
A->B->A) where no fresh mount occurs.

ScrollView-based views (Offline Manager, Settings) use visible-height for
the overflow check; ListView/Flickable views use height. Favorites' shared
albums/artists/playlists/labels Flickable matches the active tab's scope
dynamically.

cc
2026-06-08 15:53:40 -06:00
vicrodh 641aa22c17 feat(slint): scroll-position restore on back/forward (Local Library)
Scroll-position restore was never implemented (only a TODO in nav.rs);
back/forward tracked which page was visited, not where it was scrolled.

Add per-history-entry scroll memory: the mounted scroll container reports
its live viewport-y via NavState.report-scroll, so every navigation (fresh
record + back/forward) stamps the outgoing entry without touching the ~30
record call sites. go_back/go_forward hand the saved scroll back to the
shell, which arms NavState.restore-scope + scroll-restore before switching
the view. A container restores only when its scope matches, once per mount,
and only after content has laid out (viewport overflows) to dodge the
clamp-to-0 on async data load; it clears the scope once consumed.

Wired the four Local Library lists (albums, tracks, folders, artists rail).
Tracks is paginated, so its restore is best-effort within loaded pages.

cc
2026-06-08 15:38:35 -06:00
vicrodh ed7891c697 feat(slint): play-fetching spinner (now-playing bar + track row + album button)
A play can take seconds before audio starts (esp. Plex, whose resolve downloads
the whole file) with no feedback today. Add a source-agnostic loading indicator:

- A loading flag keyed to PENDING_PLAY_ID, raised at the top of play_audible
  (before the Plex/Qobuz/local resolve — single chokepoint, covers auto-advance)
  and cleared by the poll loop when the requested track's audio actually starts
  (position > 0). Cleared on every play-error early-return and force-cleared on
  queue-finished / peer-controller / ephemeral-stop so it never sticks.
- Watchdog: force-clear after 45s if the engine accepted a play that silently
  never advances (undecodable-but-valid file) — otherwise the spinner is eternal.
- Spinner on three surfaces driven by NowPlayingState.loading: now-playing bar
  (cover overlay + play/pause button), the active track row's play cell, and the
  album big play button (gated to the album actually being launched).

Audio backend / playback routing untouched. The spinner only communicates the
wait; the underlying Plex fetch latency is fixed separately (progressive streaming).

cc
2026-06-08 12:40:06 -06:00
vicrodh 5939c090ca feat(slint): Plex quality hydration on album-open + consistent quality badges (port slice 6)
Hydration:
- qbz-plex: additive plex_hydrate_album_quality(base, token, rating_keys) — per
  key plex_get_track_metadata (batch 5, 5s/call timeout, skip failures) then the
  COALESCE write-back plex_cache_update_track_quality; awaited before re-read.
- qbz-player/qbz-core: patch_plex_quality / patch_plex_queue_quality patch the
  in-memory queue in place by rating_key (for the now-playing stamp).
- qbz-slint: album-open trigger (gated on DB-NULL, not a value heuristic) then
  refresh of all badge surfaces — album-card grid (re-read aggregate + patch
  every model + the raw filter source), album-detail header + rows, now-playing
  stamp. Re-sync stays routed through plex_cache_save_tracks (carry-over).

Quality badge consistency (the four surfaces must agree — and pre-hydration
show the filetype, not nothing):
- One shared classifier crate::quality::badge(format, bit_depth, sample_rate) ->
  (tier, detail, tooltip); the album card, album-detail header and track rows
  all route through it (kills the per-surface duplication that produced
  "96 kHz" vs "96000 kHz"). sample_rate normalized to kHz via a >=1000 guard so
  Qobuz's already-kHz values are never re-divided (no "0.041 kHz").
- New "lossless" tier: an un-hydrated lossless file (FLAC/WAV/ALAC… with unknown
  bit depth) shows a generic filetype badge ("FLAC") instead of an empty badge,
  rendered in QualityBadge (compact chip) + QualityBadgeFull; upgrades to
  "24-bit / 96 kHz" after hydration. Local/Qobuz/mp3 unchanged.

cc
2026-06-08 10:27:28 -06:00
vicrodh f7619a92fc feat(slint): Plex creds store + Settings auth/PIN UX (port slices 0-2)
Fresh Slint-only Plex integration foundation (Tauri untouched; the Tauri
code is slated for deletion at port end, so creds are not shared):

- slice 0: fix the false Plex docs in local_library.rs
- slice 1: add the qbz-plex dependency to qbz-slint
- slice 2a: qbz-app/settings/plex.rs per-user store (10 fields, idempotent
  4->11 column migration keeping creds, 8 tests) - frontend-agnostic,
  no tauri::State (ADR-006)
- slice 2b: Settings > Local Library Plex section, 1:1 with the complete
  Tauri feature - master toggle + collapse, LAN-only address gate, PIN auth
  (2500ms poll, self-stopping on section-leave/expiry/disconnect),
  manual-token fallback, ping + machine-id capture, multi-section pick
  (default-all), disconnect vs clear-cache; once-per-session background
  refresh (no cache_clear churn on section re-entry)

cc
2026-06-07 21:44:33 -06:00
vicrodh 82129cff35 feat(slint): LocalLibrary ephemeral folders + Folders tree 1:1 port
Ephemeral folders: open / browse / play a folder without indexing it —
album-grouped pane, CUE seek fast-path (no full re-decode per virtual track),
folder-path persistence + rehydrate, "already playing" add-vs-replace choice
dialog (ephemeral-only), and now-playing / queue / menu gating so an ephemeral
track exposes no persistence actions.

Folders tree brought 1:1 with Tauri:
- Rail header: select-mode, collapse-all, open-ephemeral, tree search.
- Resizable rail (default ~20% narrower; drag handle, min 50% default /
  max 50% of the content area).
- Subfolder cover grid + grid/list toggle, recursive track count, name search;
  compact icon bulk bar (folder-check / play-next / queue / playlist / clear).
- Tree is the default mode; circular checkboxes homologated with TrackRow;
  compact rows (smaller type + icon, no ambiguous trailing count).
- Flat/Tree icon toggle (disc-album / folder-tree), floated right.

Per-source TrackRow context menu (logic, not a blanket trim): Qobuz/offline =
full; local = play/next/queue + add-to-playlist; plex/ephemeral = play/next/
queue only.

App-wide standard: Math.round pixel-snap on icon centering across the UI to
kill sub-pixel blur (CircleAction, header back/forward/collapse, and the rest).
NowPlayingBar context icon -> turntable (frees disc-album for the Folders
toggle). New assets: folder-check, folder-tree, turntable.

cc
2026-06-07 10:12:59 -06:00
vicrodh 8a78c2250f feat(slint): Linux system tray (ksni) + Wayland-safe close-to-tray
Port the Tauri tray to Slint on Linux via ksni: themed icon (auto/mono/
color), left-click window toggle, middle-click play/pause, scroll volume,
the 5-item menu, and a live tooltip reflecting the now-playing track. Tray
actions call the playback controller directly (QConnect-aware) instead of
emitting webview events. Settings reuse the shared qbz-app tray store
(per-user) wired through AppearanceState.

Window hide/show uses Slint window.hide()/show() (winit set_visible is a
no-op on Wayland) and the loop runs via run_event_loop_until_quit so a
tray-hide keeps the app alive. close-to-tray is honored on both the custom
titlebar close and the WM close (on_close_requested). minimize-to-tray is
hidden until custom window chrome exists.

cc
2026-06-06 01:44:38 -06:00
vicrodh 5bc6552e98 feat(qbz-slint): queue drag-and-drop reorder (WS-authoritative when connected)
Add drag-and-drop reordering to the Queue sidebar: a per-row drag handle, a
floating drag ghost, and a drop indicator. When a QConnect transport is
connected the reorder is routed to the cloud (CtrlSrvrQueueReorderTracks) so
order stays WS-authoritative; otherwise it moves the local queue track via
core().move_track. The Flickable is non-interactive so a stale pressed_time
cannot strand the gesture and leave phantom scroll after release.

cc
2026-06-05 21:09:33 -06:00
vicrodh 712447eb00 revert(qbz-slint): remove hover-reveal scrollbar feature
Restore ListScrollbar, TrackRow and AlbumPageView to their pre-scrollbar
state (always-visible scrollbars). The per-row hover-reveal only revealed
the bar over track rows, not over the whole scrollable viewport, so on
mixed-content views (Artist, Label) it felt incomplete. Card sizing
(Album/Playlist/Radio at 200px) is kept.

cc
2026-06-04 16:17:10 -06:00
vicrodh af2ca1b815 feat(qbz-slint): RadioCard 200px + hover-reveal scrollbar base
- RadioCard enlarged to 200x246 to match Album/Playlist cards; ForYouView +
  Spotlight row-width math updated to 200.
- Scrollbar hover-reveal: ListScrollbar gains a reveal prop (default true, no
  regression) and fades on opacity; TrackRow exposes hover-changed; AlbumPageView
  wired as the verified template (row-hover count -> reveal).

cc
2026-06-04 13:15:45 -06:00
vicrodh cdb5aa0072 style(qbz-slint): album + playlist cards to 200px, fonts -2/-1
AlbumCard and PlaylistCard are now 200x246 (was 220x266), with the larger
texts -2px and the smaller ones -1px. All album/playlist carousels and grids
follow (card-width/height 200/246) so nothing reflows: Carousel,
PlaylistCarousel, AlbumCollectionView/AlbumGrid, ReleaseGrid, Spotlight,
HomeSkeleton, DiscoverBrowseView, SearchResultsView, FavoritesView. Artist /
radio cards are untouched.

cc
2026-06-04 12:32:47 -06:00
vicrodh 14e3e3689c feat(qbz-slint): Queue+Lyrics stacking, NowPlayingBar polish, shell fill fix
NowPlayingBar / SongCard:
- Audio stamp homologated New<->Classic + vertically centred (alignment:center
  on the stamp column; New had a binding-loop card height, now explicit).
- Dot-LED output indicators (circle-dot/circle) in both modes; render-target
  badge moved into the LED row when a peer/cast device owns playback.
- Right-cluster buttons made square (32x32); context glyph -> disc-album,
  flush-left; classic stamp right margin; quality tooltip = the quality text;
  heart tooltip = Add to library; Qobuz Connect tooltip always shows On/Off.

Qobuz Connect:
- Clear the session topology on disconnect so a late event cannot resurrect
  the golden render badge (badge-persists-after-disconnect fix).

Queue / Lyrics right panel:
- Queue and Lyrics can both be open at once, stacked vertically in one column
  (independent queue-open/lyrics-open flags replace the exclusive right-panel
  int); each closable from its bar button and its own X.
- Panel width 280; compact rows (zebra), now-playing card homologated with a
  row, flexible footer search, Lyrics title sized like the Queue tabs.

Shell layout:
- Pin AppShell to the window and size the content area arithmetically
  (window - header - player-bar - padding). A Slint VerticalLayout ignores an
  explicit height and does not auto-fill its parent Rectangle, so the content
  row collapsed once the right panels became conditionally mounted; a Rectangle
  takes the explicit height and it cascades up. Bar height references the single
  Layout.player-bar-height source (note left for the future Small/Large bars).

cc
2026-06-04 11:49:24 -06:00
vicrodh 485be7d16c fix(qbz-slint): Qobuz Connect controller-mode reflection + polish
From the controller-mode debug session. All peer-facing paths gate on
is_peer_renderer_active, so renderer / local / disconnected playback is
unaffected.

- Now-playing bar + queue follow the peer authoritatively: reflect the peer's
  current track / position / volume, refresh on the peer's track changes, and
  fetch a fresh renderer state on becoming a controller (so the bar isn't stale
  until the next track change; this also fixed remote pause, which had been
  sending a stale current_queue_item).
- Route play-next / add-to-queue (single track) to the peer via
  QueueInsertTracks / QueueAddTracks (insert-after the peer's current item);
  multi-track album/playlist enqueue stays local for now. Clear the superseded
  SetVolume pending to stop the volume-drag command spam.
- Clean disconnect: disarm the liveness watchdog + clear the device list / cast
  state, so no spurious "renderer unreachable" toast and no lingering badge.
- Volume lock only for local ALSA-Direct bit-perfect (backend Alsa + plugin Hw),
  lifted while controlling; reflect the peer's volume with a 50% safety clamp
  when unknown.
- Connect button golden whenever a session is on; +2px now-playing bar height.

cc
2026-06-03 17:28:53 -06:00
vicrodh e845c59fea feat(qbz-slint): per-device-type icons in the QConnect device picker
Mirror Qobuz Connect's device icons in the connect flyout: map each renderer's
device_type to an icon (6 -> smartphone, 5 -> monitor or globe when the name is a
web player/browser, else -> speaker), matching the Tauri QconnectBadge
resolveDeviceType. Adds lucide monitor/smartphone/speaker/globe svgs, an icon key
on the QconnectDevice model computed in the event sink, and a per-row icon in the
PlayerBar device list.

cc
2026-06-03 11:19:14 -06:00
vicrodh 9bb5e6db26 feat(qbz-slint): port the local bit-perfect volume lock + force-100 (QConnect parity)
Port the rest of Tauri's volume logic to the Slint bar.

PlayerBar gains a derived vol-locked reproducing Tauri's handleVolumeChange
formula exactly: (alsaDirectHw && !isRemote) || volumeLocked. The local
ALSA-Direct (bit-perfect) lock is lifted while controlling a peer (the
!is-remote term) so the user can adjust the remote renderer; the sink-pushed
NowPlayingState.volume-locked keeps meaning only the peer-disallow half. All
five volume-interaction gates and the slider opacity now read vol-locked.

Force the local player to unity gain (set_volume(1.0)) in bit-perfect, mirroring
Tauri's playerSetVolume(100): when the backend is ALSA + the hw plugin AND a
peer is not the active renderer, set core volume to 1.0 and push
NowPlayingState.volume = 1.0. Applied (a) at the startup audio-settings load and
(b) on backend / alsa-plugin change. Gated on a new is_peer_active() so the
force-100 is skipped while controlling a peer. core().set_volume is the safe
seam; the protected device-init is untouched.

cc
2026-06-03 10:47:38 -06:00
vicrodh 0d7ac7930d feat(qbz-slint): QConnect device picker + cast-aware now-playing state
Add the CONTROLLER-mode device picker. New QconnectDevice struct + devices /
active-renderer-id / set-active on the QconnectDevState global (re-exported via
app.slint so include_modules! generates the type). The connect flyout replaces
the "No devices found" placeholder with a plain-row list (ADR-008: no pills)
when session renderers exist: each row taps to set-active and closes the menu;
the local device renders as "Play here" to pull playback back to QBZ. A
"Playing on: {name}" header shows while a peer is active.

The event sink rebuilds the device list and pushes the cast-aware now-playing
state (is-remote / cast-target / volume-locked) from session topology after
every event, replacing the prior TODO. main.rs wires on_set_active to the new
SlintQconnectService::set_active_renderer (over send_set_active_renderer).

Wired but unverified (blind build, no phone+speaker); device round-trips need a
smoke test. Slice 4 (peer-state reflection in the poll loop) deferred.

cc
2026-06-03 07:08:46 -06:00
vicrodh b0274c39d4 feat(qbz-slint): move QConnect diagnostics to Settings > Developer
Relocate the QConnect diagnostics launcher out of the now-playing bar flyout
into a new opt-in Settings > Developer section (the modal + DEV log/status code
is unchanged). Also make the status block selectable (read-only TextEdit) so
session_uuid etc. can be copied, and relabel the event log.

cc
2026-06-03 00:36:59 -06:00
vicrodh 44876d01be feat(qbz-slint): Qobuz Connect renderer + controller integration
Port the QConnect adapter to the Slint frontend so qbz acts as a full peer
(renderer + controller), inheriting the shared qconnect-app hardening.

- Device-info / join payloads + transport config (piece a).
- SlintQconnectEventSink: queue materialize / renderer-command apply /
  session-management via the shared qconnect-app orchestration; renderer
  ready-report on SetActive(true).
- SlintSessionLoopHost + service connect recipe (subscribe-before-spawn) +
  controller bootstrap + deferred renderer-join; connect/disconnect toggle
  wired in the now-playing bar flyout.
- Renderer sync: outbound RndrSrvrStateUpdated reports (position / current
  queue item / file+device audio quality) while qbz is the active local
  renderer, and inbound UI refresh (now-playing card + queue sidebar) after
  remote queue / state changes.
- Controller-side queue push: when the local queue changes while connected,
  push it to the session (QueueLoadTracks) with all-or-nothing admission
  (refuse mixed local/Plex queues; offline qobuz_download is eligible) and
  echo suppression against the last-applied / last-pushed queue.
- Error + lifecycle toasts; DEV diagnostics modal (live session status +
  selectable rolling event log) opened from the connect flyout.

cc
2026-06-03 00:11:37 -06:00
vicrodh 1ad0d7c169 feat(slint): classic now-playing bar layout
Add the second of the four switchable now-playing bar layouts plus the
4-layout architecture behind it.

ShellState.npb-mode (0=New, 1=Classic) drives mode-conditional columns in
PlayerBar; the transport cluster is extracted into TransportControls.slint and
IconButton/PlayButton into primitives/BarControls (PlayButton gains a `circle`
prop) so both layouts share them without duplicating the popups. The choice
persists via UiPrefs.npb_mode ("new"/"classic") — restored on startup, written
on the "Now Playing view" menu select.

Classic = the Tauri arrangement: transport LEFT (plain play glyph, leading
track-info, inline add-to-playlist + favorite-with-state), glass song card
CENTRE, actions + volume RIGHT. SongCard.glass is a contained card capped at a
fixed 520px; the audio stamp is ABSOLUTELY pinned to the card's right edge
(cover 56 | song info that truncates with ellipsis | stamp 140) so it stays
flush right regardless of the text layout. Track info fires from both the
title click and the transport (i). Classic uses the full-size audio stamp; the
backend LED reads PIPEWIRE.

cc
2026-06-02 11:32:01 -06:00
vicrodh da7758b9d6 feat(slint): new now-playing bar layout
Land the "New" now-playing bar as the first of the four switchable
layouts. A dedicated SongCard component holds the cover (or an idle
sleeping cat), the elidable title, the artist·album links, and the
vertical audio stamp (compact quality badge over the backend/mode LEDs).
A full-width seek bar sits on top with a buffer-driven stream lock.

The controls row uses responsive symmetric column zones (39/30/25% by
window width); the side columns share one fraction so the transport stays
absolutely centred and PLAY lands on the window centre. The song-card /
controls divider is a hard wall: the text column stretches to push the
audio stamp to its right edge, the album elides first (and never collapses
to zero) so the stamp is always visible and never crosses the wall.

Adds QualityBadgeFull compact mode, backend/mode output LED labels from
settings, a global TooltipOverlay so clipped columns keep their tooltips,
and the supporting icons.

cc
2026-06-02 06:48:36 -06:00
vicrodh 34eaebd8ef feat(slint): play-from-clicked-track, track-row quality badge, album link, playlist layout
Committed together because these changes share main.rs, playlist.rs and
PlaylistView.slint, and the new quality field is atomic across every
TrackItem builder, so they cannot compile as separate whole-file commits.

- Universal play-from-here: a single play_track_in_context dispatcher builds
  the queue from the current view's VISIBLE list and starts at the clicked
  track (playlist, album, artist, favorites, label, mix, search). Cached
  views order by the visible model; re-fetch views (album / artist top
  tracks) reorder to the in-page filter; the search "most popular" hero
  prepends to the results queue. Drops the playlist-track / mix-track arms
  that always started at track 1 (the clicked id was read from the wrong
  media-action slot).

- Album-name links in track rows open the album page (the ("album","open")
  media-action arm was missing and fell through to a no-op).

- Unified track-row quality badge: a new quality-detail field on TrackItem,
  populated from bit-depth / sample-rate in every builder through a shared
  crate::quality::detail, rendered with a bare (no chip background/border,
  centered text) QualityBadgeFull. Restores the exact bit-depth / sample-rate
  line that had regressed to a plain CD / Hi-Res tier.

- Playlist track list: reserve the favorite / download / context-menu columns
  in the column header, match the row's 36px cover cell, and anchor the
  scrollbar to the view edge (matches Local Library > Tracks).

cc
2026-05-31 21:50:51 -06:00
vicrodh aa226bbbd6 feat(slint): separate the artist portrait from the JUMP TO bar
Insert a 20px spacer before the body row. The JUMP TO bar's natural-top tracks body-row.absolute-position.y, so it drops with the row while its sticky fix-on-scroll clamp stays at 0 (unchanged).

cc
2026-05-31 21:50:29 -06:00
vicrodh d1ebf8ea1c feat(audio): release/refresh output-device button (#263)
After bit-perfect ALSA Direct grabs a DAC exclusively (hw: + D-Bus
reservation), the device stays sequestered and is invisible to PipeWire,
so switching back to the PipeWire backend can't see it without restarting
the app. Tauri had a refresh affordance for hot-plugged DACs; this is the
refresh plus an explicit release, in one button.

Backend: Player::release_device() / AudioCommand::ReleaseDevice tears the
stream down immediately (cancelling the deferred drop), freeing the hw:
grab + its reservation, then un-suspends the parked sink and resets any
forced graph clock (both self-gating). It does NOT reopen, unlike
reinit_device which would re-grab the device.

UI: a refresh-cw icon button beside the Output device select, propagated
through the settings callback chain to handle_release_device, which
releases, waits for PipeWire to reclaim, then re-enumerates so a freed or
hot-plugged DAC reappears without an app restart.

cc
2026-05-31 17:41:01 -06:00
vicrodh bdb9cadad6 feat(ui): speaker glyph + searchable output-device select
QbzSelect badges now render a "BP" tag for bit-perfect outputs and a
speaker glyph (volume-2) for the rest (real outputs that play audio but
not bit-perfect) — an addition, not a replacement. Only the device select
passes badges, so other selects are unchanged.

Add opt-in `searchable`: long output lists overflowed at 1440p, so the
open list gets a case-insensitive filter box and a 360px scroll cap.
Filtering hides non-matching rows by visibility only (model order intact,
so selected(i) keeps the original index). Backed by a new Rust
TextUtil.contains-ci pure callback (Slint 1.16 has no contains builtin).

cc
2026-05-31 16:50:29 -06:00
vicrodh 171d05f432 feat(slint): wire AlbumView control buttons (shuffle/radio/artist/label)
The album header had several dead icon buttons and a non-navigating label
card. Wire them:
- Shuffle -> play_album_shuffled (SystemTime-seeded xorshift Fisher-Yates,
  same as the playlist shuffle, no rand dep) + ("album","shuffle") arm.
- Radio -> existing ("album","radio") album-radio path.
- Artist (user icon) -> open the album artist.
- Label sidebar card -> navigate to the label page; added label-id to
  AlbumState (populated from album.label.id) and made the card clickable.

Still pending for AlbumView (next batches): multi-select (square-check +
MultiSelectBar), the "more" ellipsis menu, the digital-booklet viewer, and
add-to-mixtape. Awards pages deferred (1:1 Tauri port, separate session).

cc
2026-05-30 21:20:40 -06:00
vicrodh ac192a0c4c feat(slint): playlists flyout from the header when the sidebar is closed
With the sidebar fully closed there was no way to reach playlists. Add a
playlists button to the closed-sidebar header cluster that opens a flyout
over SidebarState's entries (folders + playlists), six rows visible with
the rest scrolling, plus an always-visible footer linking to the Playlist
Manager. Rendered at the AppShell level (SidebarPlaylistsPopup +
SidebarPlaylistsPopupState); header button needs no absolute-position
correction (not inside a Flickable).

cc
2026-05-30 21:02:53 -06:00
vicrodh 321d3ba116 fix(slint): track drag no longer stays glued after release
The drag-end relied on the row TouchArea's pointer-up, which only arrives
if the row still holds the grab. Inside a ListView (or a Flickable that
stays interactive) the scroll gesture can steal the grab mid-drag, so the
up never reached the row and the ghost stayed glued to the cursor. Add a
`changed pressed` safety net that ends the drag on any release or grab
cancel (both paths guard on ta.dragging, so end() runs once). Also give
LabelPageView's Flickable the interactive: !DragState.active flag the
other track views already have.

cc
2026-05-30 20:18:11 -06:00
vicrodh f249558573 feat(slint): head collapsed-sidebar nav flyouts with the section name
SidebarNavRow (mini sidebar) opened its section flyout with no header;
the closed-sidebar CompactNavBtn dropdowns show the section name + a
hairline. Set HeaderMenuState.title to the row label so the mini flyouts
match (same @tr strings).

cc
2026-05-30 20:18:11 -06:00
vicrodh 63a80f49dd feat(slint): hover tooltip on collapsed-sidebar folder/playlist rows
In the mini (icon-only) sidebar the rows show no name. Add a hover bubble
to the right of each folder/top-level-playlist row surfacing the name
(folders also append their playlist count), mirroring the native title
Tauri puts on its collapsed buttons. Rendered at the AppShell level
(SidebarTooltip + SidebarTooltipState) because the sidebar clips its
overflow; anchored with the same Flickable absolute-position correction as
the folder flyout. Closes on click so it does not linger behind the flyout.

cc
2026-05-30 19:48:29 -06:00
vicrodh 4c0a849371 fix(slint): correct absolute-position double-count inside Flickable
absolute-position of an element nested in a Flickable double-counts the
element's own layout offset (measured: abs.y = true_y + root.y), so the
collapsed-sidebar folder flyout drifted down by one row-pitch per row
(lower folders left the viewport) and the track drag ghost plus the
playlist drop-target hit box drifted the same way. Subtract root.{x,y}
to recover the true window position; scroll-safe because root.{x,y} is
the row's fixed in-layout offset, not the viewport scroll. Rows outside
a Flickable (the section nav rows) are unaffected and left as-is.

Sites: SidebarRow folder-flyout anchor + recompute-drop hit box;
TrackRow drag down/threshold/start/move coordinates.

cc
2026-05-30 19:03:11 -06:00
vicrodh 89734ae3ab fix(slint): instant local queue, mouse nav buttons, narrower toolbar controls
- Local Library Tracks: build the play queue from the already-loaded rows
  (instant) instead of re-querying the DB + filling covers on every click.
- Mouse back/forward buttons now drive history nav over track rows and album
  cards (a TouchArea swallows them, so each interactive surface forwards them;
  the AppShell catcher only saw the gaps).
- ExpandableSearch open width trimmed 20px (no longer rides over left controls
  on small screens).
- QbzSelect toolbar (Group/Sort/filter) trimmed 40px total.

cc
2026-05-30 16:53:11 -06:00
vicrodh 13f43b802b fix(slint): flyouts/idle, header nav layout, toasts, settings, search
Flyouts & idle (main effort):
- Header dropdowns now glue under the tab: open-menu resets from-sidebar +
  title (a stale sidebar flyout state was flinging the panel far away).
- Idle-close now pauses while the cursor rests on the TRIGGER too (full
  header tabs gained the trigger-hovered logic the others already had).
- Dropped the dropdown caret (saves width on small screens).

Header nav layout:
- Section nav moved INTO the left-controls cluster, after the three sacred
  buttons, so it never overlaps them. Full text tabs when nav-in-sidebar off
  and sidebar not closed; compact icons when closed — mutually exclusive (no
  more duplicate/cluttered header).
- Search field gives up 60px (animated) when the header hosts the nav.

Settings made functional:
- Toast component mounted in AppShell (it was never rendered) + the in-app
  toasts toggle drives ToastState.enabled.
- "Show album artwork in Local Library track list" now drives the Tracks/
  Folders rows. Both rows un-flagged as unsupported.

Collapsible search (ExpandableSearch, app-wide):
- Smooth open AND close (width animates both ways; magnifier fades).
- Reliable auto-focus on open (deferred via a one-shot timer).

cc
2026-05-30 15:59:25 -06:00
vicrodh 682719cab0 feat(slint): library/sidebar UX overhaul + theme switching + fixes
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
  full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.

Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.

Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
  grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.

cc
2026-05-30 15:40:35 -06:00
vicrodh 201082d4a1 feat(locallibrary): complete Albums 1:1 + dedicated local album view with versions
Albums tab — full 1:1 with Tauri in one pass:
- Switch from the chunked-paginated grid to a full-load + in-memory derive so
  grouping/filtering span the whole set. derive_albums = search + sort + group
  (off / by letter with A-Z jump / by artist, both with section headers) + the
  quality/format/source filter (1:1 with matchesQualityFilters: OR within a
  group, AND between groups; empty group passes).
- Grid/list view toggle; the list view shows a SOURCE column (Local/Offline/
  Plex), placed after TYPE. Filter popup now FLOATS (sibling of the content
  layout, not a layout slot) with an active-count badge. Fixed the year sort
  (sort_album_items maps year-asc/desc — was also silently broken in Folders).
- Album covers dual-set by id across the full set + visible + grouped sections.

Dedicated local album view — SEPARATE from the Qobuz AlbumView:
- New ContentView::local-album + NavEntry::LocalAlbum + ui/album/LocalAlbumView.slint,
  reusing TrackRow/NavButtons/CircleAction/QualityBadge internals but ONLY local
  actions (play all / shuffle / edit tags / add to playlist) — no Qobuz chrome.
- Versions: an album's tracks are split by source directory into versions, so
  multiple copies of the same album no longer merge into a duplicated track
  list. Best-quality version is the default; a source-icon version picker
  (hard-drive / Qobuz / Plex glyph in both the selected display and the options)
  switches the shown copy in place. The album cover is the best-quality version
  that has one, falling through versions, else a placeholder (covers backfilled
  from disk via fill_missing_covers). Header padding-top 11px matches the app.

Fix: Artists tab deadlock — fetch_missing_artist_images snapshotted names with a
blocking rx.recv() called from inside an event-loop closure, freezing the UI
(static spinner, no error). Names are now snapshotted on the UI thread and
passed in; the worker never blocks the event loop.

cc
2026-05-29 19:16:09 -06:00
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