QConnect is unavailable in any offline flavor: connect() refuses while
offline (toast + same reset path as the api gate), a watcher subscribed to
the engine force-disconnects through the exact manual-disconnect path on
every offline edge (badges and lifecycle clear identically), an end-of-
connect re-check closes the flip-while-connecting race, and the session
loop host skips its controller re-join while offline. No auto-reconnect on
the online edge by design. The shared remote stream feeder stays ungated
(it also serves Plex, which must survive induced offline).
main.rs carries the wiring for all three slice-3 pieces: the offline
force-disconnect spawn, the offline-gated save-as artwork fallback, and
the favorites disk seed in both shell entries.
cc
Hoists Tauri's FavoritesCacheStore into qbz-app::settings::favorites_cache
(same per-user favorites_cache.db filename and schema, file shared across
frontends; 4 store tests). Slint's fav_cache now binds the store per user,
seeds the in-memory id set from disk at session activation (online,
restore, recovery and offline entry - fixing the start-offline cold-hearts
gap Tauri never closed), writes through on every favorite mutation, and
full-replaces the disk mirror on network refreshes. Queue hearts read the
shared cache instead of re-fetching from the network. All track-favorite
mutation paths are gated with a toast while offline (read-only hearts).
Part of offline-mode slice 3; main.rs wiring rides the slice-3 wiring
commit.
cc
Shared artwork HTTP client gains the missing 10s total timeout (half-up
networks could pin the 16-permit semaphore). fetch_cached_http short-
circuits while offline: disk hits serve, misses return the placeholder
immediately with zero network and no negative caching. Now-playing meta
resolves MPRIS art to a cached file:// URL (or none) instead of an
unfetchable https URL while offline; system notifications skip their
blocking artwork download offline via an injected flag, keeping
qbz-media-controls frontend-agnostic.
Part of offline-mode slice 3; main.rs wiring rides the slice-3 wiring
commit.
cc
New OFFLINE MODE group above the cache group on the Offline settings page:
read-only tri-state status row (online / no connection / offline mode
enabled, with captive-portal hint) plus an honest Check now that forces an
actor re-probe; Enable Offline Mode toggle driving the shared engine (issue
#279 stream_first_track snapshot/restore included, Playback panel re-synced
after); Show Network Folder Content toggle mounted only while induced
offline is on. Deliberate deviations from Tauri per the approved spec: no
probe gate on disable, no dead cast/scrobbling toggles.
cc
New OfflineState global mirrored from the shared engine by a status
forwarder task. Login screen: start-offline link gated on a previous
session existing, connectivity-down callout pointing at it (with captive
portal hint), init-error callout fed from the restore flow. Shell: D2
recovery banner (connection back during an unauthenticated offline
session) with one-click token sign-in that lifts the offline gate for the
login attempt and re-enters the full online shell.
Double-init hardening for the recovery path re-running enter_shell over a
live shell: synchronous guard on the Linux tray init (its OnceLock was set
asynchronously, leaving a duplicate-tray window) and a start guard on the
playback poll loop (a second loop would double-advance the queue).
cc
Start offline on the login screen now enters a full offline session instead
of an empty shell: session scaffolding at the last user, local library DB +
mixtape migrations, offline cache + cached-ids warm, per-user pref stores,
tray and media controls, playback poll loop, settings snapshot - mirroring
Tauri's activate_offline_session checklist. Refuses when no previous session
exists (never opens the empty user-0 profile).
New offline_mode glue binds the shared engine + connectivity actor for the
app lifetime and owns the per-user SubscriptionStateStore: mark_valid on
every successful login, mark_invalid only on an explicit IneligibleUser
verdict, binary 30-day-grace playback predicate, and the Tauri-mirrored
purge-at-activation consumer (previously the Slint build never opened the
store, leaving the grace model inert).
Token-survival fix: a network-failed session restore no longer clears the
saved OAuth token; only an explicit auth rejection does. login_with_token
now preserves the typed ApiError (CoreError::Api) so callers can tell the
two classes apart.
cc
All 23 request sites in QobuzClient now obtain the reqwest client via the
private http() choke point, which fails fast with the non-transient
ApiError::OfflineMode while offline mode is active (D3: zero Qobuz services
offline). Warm-start init still serves cached bundle tokens offline and only
skips the background refresh; the cold bundle fetch fails fast instead of
waiting out network timeouts. Behavior with the gate open is unchanged.
Known bypass left for the QConnect slice: get_http() feeds the qws
createToken POST in qbz-slint; QConnect gets its own connect()-level gate
per D5 (never available offline).
cc
New qbz_app::offline_mode (frontend-agnostic, ADR-006): three-state mode
machine (Online / RealOffline session-scoped / InducedOffline persisted),
Tauri-compatible offline_settings.db store with the issue #279
stream_first_track snapshot, and a layered connectivity actor (default-route
signal on Linux, audio liveness window, IP-literal + vendor-diverse strict
probes, asymmetric hysteresis with confirmation burst, captive-portal and
suspend/resume handling).
qbz-qobuz gains the process-wide offline_gate and ApiError::OfflineMode (the
single Qobuz choke point; HTTP funnel wiring lands next).
SubscriptionStateStore gains offline_playback_allowed: binary 30-day-grace
playback gate for the offline cache, never a preview-degradation path.
cc
The DetailRow had a full-row `row-ta` TouchArea (clicked -> open the item/album)
declared AFTER the cell layout. In Slint a later-declared element paints ON TOP,
so row-ta was frontmost over the whole row and captured every click -- including
the ⋯ button -- so the menu never opened and the row just opened the album. Moved
row-ta to be declared BEFORE the cell HorizontalLayout, so it sits behind the
per-cell TouchAreas (checkbox / play / title / subtitle / ⋯) and only catches the
empty gaps. The ⋯ menu (and the play overlay / title) now receive their clicks.
cc
- Detail row ⋯ menu (and the hero overflow + filter popups) were PopupWindows
with no explicit height, so they collapsed and the rows below painted over them.
Gave each an explicit height (+ close-on-click) like every other app menu, so
they paint above the rows.
- Row menu Remove now actually removes: it was a no-op ("remove" wasn't a RowMode)
-> wired to remove the item (repo::remove_item by position, reload). Add-to-queue
was also silently broken (emitted "play-later", unparsed) -> now "add-to-queue".
- Sidebar playlist right-click menu: added "Add to Mixtape/Collection" (opens the
picker with the playlist payload).
- Album -> Collection: an album can now be added to ANY collection kind incl.
artist_collection (removed the ArtistCollection exclusion in load_rows; Tauri
allows adding an album to an artist discography).
Offline in the row menu is net-new (Tauri's collection row menu has none; it would
be a batch resolve-then-cache) and is NOT added here. Backend crates untouched.
cargo check -p qbz-slint green.
cc
The ToolBtn row used an absolute `x: 8/10px` offset together with
`width: preferred + padding`, which pushed the icon (and label) off-centre —
the Filter and Select buttons read as misaligned. Replace the x offset with
symmetric padding-left/right inside the HorizontalLayout and size the button to
the layout's natural width, so the glyph is centred deterministically.
cc
Per the user's 2nd visual review:
- Local/Plex album art now renders in the detail rows: resolve_items backfills
empty-art rows from the first resolved track (source-aware dispatch ->
spawn_local_or_plex_loads with live Plex creds); to_item re-hydrates from cache.
- Type/source FILTER homologated: a LABELED filter button (FavGenreButton idiom,
accent-fill active) + a checkbox popup; the "Select" toggle is now ICON-ONLY
(square-check-big, no text) matching Favorites/LocalLibrary.
- New primitives/SelectionCheckbox.slint (the canonical CIRCULAR 13px selection
checkbox, accent fill, check / minus for indeterminate). Replaced all 5 square
MyQBZ checkboxes (detail list+grid, filter popup, builder per-row + tri-state
select-all). KindRadio dots stay round radios (single-select, correct).
- Quality column shows a LoadingDots skeleton while resolving (not empty).
- Album view header buttons: deleted AlbumPageView's private divergent CircleAction
copy -> the shared primitive, so it matches every other view's thickness.
CollectionsView kind-filter stays QbzSelect (single-select group-by = the app's
group-by idiom). Backend crates untouched. cargo check -p qbz-slint green.
cc
Per the user's visual review. Replaces the reinvented MyQBZ controls with the
app's existing primitives and resolves the per-item data left unresolved.
Cross-cutting (all MyQBZ views):
- Removed the per-view "Back" header (the app uses the global < > nav).
- 32px L/R content padding so the ListScrollbar never rides over rows/cards.
- Homologated page padding + Typography tokens to Favorites/LocalLibrary.
- No more blue accent-filled pills / non-app colors.
Component homologation (deleted the reinvented locals + dead code):
- Grid/list + detail view-mode toggle -> the app ViewToggle (ToggleButton,
surface-hover active, never accent). Builder ORDER-BY segmented de-blued too.
- Sort/filter -> QbzSelect (sm); search -> ExpandableSearch (sm); "+ New" ->
NewActionBtn (surface-elevated circle, single centered glyph). Header "+ New"
hidden when empty so the empty-state CTA is the only one (no duplicate).
- Deleted PrimaryCta / MyQbzSearch / ControlBtn / ViewSeg / ViewModeGroup /
SortItem / ColMenuItem + the hand-rolled sort/filter popups + reset.
Data (resolveItems, was deferred):
- Quality column populated from the resolved tracks (QualityBadgeFull).
- New ui/primitives/SourceGlyph.slint: full-color Qobuz/Plex glyphs + a
text-muted local hard-drive -- distinguishes Local vs Plex, no accent blob.
Reused in the detail + builder rows.
- Type column shows the real release type (ALBUM/EP/SINGLE), not always ALBUM.
- Local/Plex album art now loads in the detail rows (split artwork dispatch).
- Collection grid card renders custom_artwork_path.
- Builder rows: 44px + a hairline so quality badges read separated.
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12e (final fidelity touch-up).
- src/myqbz_view_prefs.rs: per-user JSON store (users/<id>/collection_view_prefs.json),
keyed by collection id, persisting the five §18 fields (view_mode, sort_by, sort_dir,
type_filter, source flags qobuz/plex/local). searchQuery + selectMode stay transient.
Restore-on-open with a PREFS_HYDRATED gate (early setters can't clobber the load);
persist on every toolbar change (sort/type/source/reset/view-mode); clear on delete.
3 unit tests. 1:1 with Tauri's collection-view-prefs.{id} + prefsHydrated.
- myqbz_play::shuffle now persists play_mode='album_shuffle' (best-effort set_play_mode +
reload so the overflow play-mode label flips) ONLY when not already album_shuffle --
mirrors handleShuffle; normal Play never persists.
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12d.
- Discography Builder: uppercase eyebrow + ORDER BY + 6 column labels (BUILD ARTIST
COLLECTION / YEAR/TYPE/ALBUM/TRACKS/SOURCE/QUALITY); Create now disables on a
whitespace-only name (name-trimmed-empty computed in the controller).
- Expanded-mode: per-item inline-track cache keyed by the stable source|source_item_id
(survives filter/sort/search re-derive instead of re-fetching), cleared on open.
- Inline tracks: go-to-album passes the parent item_type (playlist parent routes to
the playlist view); duration 0 -> "--:--"; track number from the resolver order.
- Detail bulk bar: completed the §13.1 set (Add to queue, Play next, Add to playlist,
Add to Mixtape/Collection, Remove, Clear) via MultiSelectBar slots + bulk_enqueue
(reuses the resolver) + the existing playlist picker; no invented primitive.
- Stale comments corrected (dj-mix wired; mix-modal primary = Add to queue).
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12c (user directive: use the app's circular controls, no invented
buttons; fix the glyph-stuck-left bug).
- Hero §3.6: deleted the local ActionCircle; the 4 hero controls are now the
shared CircleAction (Play primary = filled white disc + black glyph; the loading
spinner is now available = more 1:1). Added `enabled` to CircleAction (additive).
- All MyQBZ modal footers (Create / Edit / Mix / AddTo / Discography) now use the
shared QbzPrimaryButton (+ a new `destructive` variant for Delete) and a new
shared SecondaryButton (promoted from AppearanceSettings' inline copy).
QbzPrimaryButton auto-sizes as a footer chip while staying full-width in login
(verified: login stretches it in a VerticalLayout; footers take preferred width).
- Kept legitimately-local icon+label controls (grid "+ New" CTA, AddToMixtape
picker chips, toolbar sort/filter/view-mode pills) but verified every glyph is
centered.
- Fixed 3 glyph-stuck-left bugs (DiscographyBuilder checkboxes used non-snapped
centering -> the Math.round(.../1px)*1px crisp idiom).
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12b.
- Expanded view-mode: replaced the "Inline tracks coming soon" placeholder with
real per-item inline track expansion. Matches Tauri 1:1 (no chevron -- every
album/playlist row auto-shows its tracks). fetch_item_tracks reuses the shared
ProdItemResolver (display-only; spec §8 routing matrix); renders the app's
TrackRow indented; inline-track media actions (play/play-next/play-later/go-to).
- skip-to-item: headless myqbz_play::skip_to_next_item/skip_to_previous_item over
the qbz-core queue using the crate's next_item_index/previous_item_index (3s prev
rule). UI trigger DEFERRED -- Tauri has zero frontend callsites for skip-to-item
(no skip-album button exists), so wiring one would invent UI; the global next()/
previous() are untouched.
Known polish (S12 fidelity pass): expanded-mode per-item cache should key on
source|source_item_id (survive filter/sort) + viewport-gate; inline go-to uses
item_type; duration "--:--". Backend crates untouched. cargo check green.
cc
Phase-2 Slice 12a. Per the user's decision (DQ3), the "My QBZ" rename/change-icon
lives in Settings > Appearance, not a sidebar context menu:
- src/myqbz_prefs.rs: per-user {label, icon_path} persisted to
users/<id>/myqbz_branding.json (scoped like plex_settings/tray_settings);
init_for_user/seed/set_label/pick_icon(rfd)/reset_icon. Blank label coerces to
"My QBZ"; a missing/deleted custom icon falls back to the default my-qbz.svg
without crashing (path preserved). 4 unit tests.
- ui/settings/AppearanceSettings.slint: a "My QBZ" SettingRow (icon preview +
label LineEdit + Change icon... + Reset).
- ui/shell/Sidebar.slint: the My QBZ row binds label/icon to MyQbzBrandingState
(raw-icon flag renders a custom user image untinted; default glyph stays tinted).
- Seeded on enter_shell.
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 10.
- ui/myqbz/MyQbzMixModal.slint + src/myqbz_mix.rs: the DJ-mix "random queue"
modal. Open resolves the collection in-order + shuffle::unique_track_count
(deterministic) for the slider max (buildSizeOptions). Shuffle re-resolves,
runs dedup_by_similarity + hybrid_sample with a thread RNG kept in a SYNC scope
(dropped before the next .await -- !Send confinement), then replace-plays +
stamps queue-source + touch_play; toasts "Playing N of M" when the per-album cap
shrinks the pool. Wired on_dj_mix.
- Detail bulk bar (MultiSelectBar, bottom-docked): add-selected -> AddToMixtapeModal
with the selected payloads; remove-selected -> repo::remove_item (highest position
first so compaction does not invalidate pending positions) -> reload + clear
selection + toast.
Deferred to polish: the bulk bar's queue / add-to-playlist / clear-X groups (§13.1).
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 8. Adds "Add to Mixtape/Collection" everywhere "Add to playlist"
lives, so collections can be populated:
- ui/myqbz/AddToMixtapeModal.slint: the picker (search + collection list with
kind icon/name/count + "Already added" via item_exists + kind tag) and the
create-new-inline panel (name + kind), per spec 21. Mounted once at root.
- src/myqbz_add.rs: list_collections (kind-restricted: track/playlist->mixtapes,
album->mixtapes+collections, never artist_collection; recency-sorted); add via
add_item_with(allow_duplicate=false) counting added/skipped-duplicates + toast;
create_and_add reuses create_collection. All via with_db -> with_connection.
- Entry points wired: per-track (universal via TrackContextMenu/TrackRow -> search,
favorites, album rows, local tracks, now-playing), Qobuz album, local/plex album,
playlist (PM), bulk local tracks. Replaced the PlaylistManager add-to-mixtape no-op.
Qobuz bulk omitted by design (spec 50 negative-space).
Note: adversarial reviewers were blocked by a session limit; verified manually
(cargo green, confined to qbz-slint, backend untouched, ADR-005/006, coverage per
spec 50).
cc
Phase-2 Slice 4. The grid "+ New" buttons open a create modal (name + kind
radio: mixtape/collection, artist_collection excluded), create via the shared
crate, and drop the user into the new collection's detail (1:1 with Tauri's
submitCreateModal -> openMixtapeDetail). The new row prepends to its kind.
- ui/myqbz/CreateMyQbzModal.slint (mirrors CreatePlaylistModal/CreateFolderModal
idiom; spec 30 create-modal geometry).
- MyQbzCreateState/MyQbzCreateActions in state.slint; mounted in AppShell.
- src/myqbz.rs create_collection via with_db -> with_connection ->
qbz_mixtape::repo::create_collection (Manual, 80-char cap).
- Un-stubbed on_create_mixtape/on_create_collection; trim + busy guards; toast.
Backend crates untouched. cargo check -p qbz-slint green.
cc
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
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
New crate crates/qbz-mixtape holding the Mixtapes+Collections backend, ported
1:1 from src-tauri/src/mixtape and re-typed against the real shared crates so it
runs headless (no tauri::State, no #[tauri::command] wrappers) per ADR-005/006:
- schema.rs / repo.rs / shuffle.rs copied verbatim (SQLite schema + migrations,
CRUD repository, DJ-mix similarity sampler). enqueue.rs re-typed to
qbz_qobuz::QobuzClient, qbz_library::LibraryDatabase and qbz_plex, dropping the
src-tauri crate::api / crate::library shims.
- ItemResolver is frontend-agnostic: ProdItemResolver takes a caller-supplied
sync local-resolution closure so &LibraryDatabase (!Sync) never crosses an
.await. resolve_local_item centralizes the full Local dispatch matrix
(Album / Track i64-parse / Playlist-unsupported) so frontends cannot drift.
- src-tauri left untouched: Tauri is removed at the end of the Slint port, so the
temporary duplication is intentional and zero-risk to the working Tauri build.
Adds qbz-mixtape to the workspace members + rand/strsim workspace deps.
Phase-2 Slice 1 of the MyQBZ Slint port. 57 crate tests pass.
cc
- 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
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
A Plex album_key is a hash of artist+album, so two distinct Plex albums
that share a title (e.g. a matched album plus loose files added as a second
album) collapsed into one cache album and their tracks interleaved
(1,1,2,2,...) in the album view with no way to pick a version.
Capture parentRatingKey (the real per-album Plex id) on sync, store it in a
new plex_cache_tracks column, and key the album-view version grouping on it
so each Plex album becomes its own version in the existing header picker
(best quality first). Pre-resync rows lack the column and fall back to the
old single-group behaviour; a re-sync backfills it.
cc
The shared open-album callback always recorded a Qobuz Album entry, so a
Plex/local item (Home "Recently played", the now-playing bar's "Go to
album") opened an empty Qobuz album view. Detect the Local-Library/Plex
metadata group key (album|artist, a folder path, plex:, or the
__unknown_album__ sentinel — none of which appear in numeric Qobuz ids) and
route those to the LocalAlbum view instead.
cc
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
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
Two Plex-port fixes (both in the Plex play/data path):
1. Progressive streaming — play starts in ~1s instead of ~10s. play_plex_audible
no longer downloads the whole file before play_data; it resolves only the
part URL (new additive qbz_plex::plex_resolve_part_url, no body GET) and feeds
it into the player's streaming source (play_streaming_dynamic) via a shared
feeder module (remote_stream.rs, extracted from the QConnect renderer's HTTP
feeder — both now share it). Bit-perfect preserved: the original /file bytes
are streamed (no transcode), device init derives from the decoded stream; the
protected audio backend is untouched. Cast/DLNA keep the whole-file path.
Falls back to whole-file play_data on a non-direct part / streaming-setup error.
2. Re-sync no longer wipes hydrated quality — the sync did plex_cache_clear()
(full wipe) BEFORE the per-section save_tracks, defeating save_tracks' COALESCE
hydration carry-over, so already-hydrated albums reverted to a bare FLAC badge
on every settings-open / background-refresh / section-toggle. Replaced the full
wipe with a new additive plex_cache_prune_sections(keep) that removes only the
DE-selected sections, keeping the selected sections (and their hydration);
save_tracks then re-applies its per-section carry-over.
cc
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
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
Plex cover images were fetched full-res on every surface (Slint built the raw
/library/.../thumb URL), where Tauri requests a server-transcoded thumb sized to
the render target. On a real library this made the views feel heavy and starved
the LAN bandwidth that track playback needs.
- qbz-models: ArtworkRef::PlexThumb gains a size: Option<u32>; new plex_thumb_url()
emits the Tauri-format transcode URL when Some
(/photo/:/transcode?url={enc}&width=N&height=N&minSize=1&X-Plex-Token=), raw
tokenized path when None (album/MPRIS full-res preserved). urlencoding dep added
to match encodeURIComponent. Tests extended.
- qbz-slint: each PlexThumb construction site passes its surface decode size
(grid/detail 264, queue/now-playing-item 96, now-playing bar 160, MPRIS full-res);
fetch_and_decode_ref uses plex_thumb_url.
- artwork.rs: static LazyLock<reqwest::Client> reused in fetch_cached_http instead
of reqwest::get (per-image client) — keep-alive + no fd churn (EMFILE smell).
Audio path untouched; local/Qobuz artwork unchanged.
cc
A Plex queue track's artwork was file://-prefixed onto its raw /library/ thumb
path, breaking the cover on all three surfaces. Approach (b) from the funnel
review: keep the raw path in QueueTrack.artwork_url (token never persisted) and
resolve to a fresh tokenized PlexThumb from live plex_settings creds at each
surface.
- qbz-models: additive QueueTrack::artwork_ref_with_plex(base_url, token) — a raw
/library or /photo path + creds => ArtworkRef::PlexThumb, else falls back to
artwork_ref() (http/file:// unchanged). 4 regression tests.
- playback.rs: local_queue_track keeps the raw path for Plex rows (no file://
poison); refresh_now_playing_meta uses artwork_ref_with_plex -> fixes the
now-playing bar, MPRIS (to_mpris_url), and the desktop notification.
- queue.rs: load_artwork threads creds + a PlexThumb branch for the queue panel.
- Local/Qobuz/ephemeral artwork byte-for-byte unchanged.
cc
Both tabs were Plex-blind. Loader-level merge (no qbz-library change):
- qbz-plex: additive PlexCachedArtist + plex_cache_get_artists() (aggregates
the flat plex_cache_tracks by normalized artist) + plex_cache_count_tracks().
- Artists tab: fold Plex artists into the local set before merge_artists
(same-name local+Plex collapse, summed tracks); artist-detail album grid +
counts switch to the Plex-aware get_albums_metadata_page; portraits fall back
custom/Qobuz -> Plex thumb -> album cover, dispatched via spawn_local_or_plex_loads.
- Tracks tab: prepend the bounded Plex set (<=5000) once at offset 0; later
pages stay pure-local so the virtualized 16K-row LIMIT/OFFSET path is intact.
- Tracks badge now includes the Plex count (matches albums/artists badges).
- All gated on plex_settings enabled (Plex OFF = byte-for-byte local-only).
Plex track id namespaced into [2^40, 2^41) so it cannot collide with local
track ids (< 2^40) or ephemeral ids (>= 2^48) in the merged Tracks tab — the
string rating_key is preserved in file_path, so playback is unaffected.
cc
Plex tracks produced no audio: local_queue_track had no "plex" arm so they were
enqueued as source="local", and there was no Plex resolve+play path.
- local_queue_track tags source="plex" and carries the string rating_key in
source_item_id_hint (the numeric queue id is a hashed/parsed form; the resolve
needs the original key); persisted in the session queue store.
- play_audible gains a "plex" arm -> play_plex_audible.
- play_plex_audible: qbz_plex::plex_resolve_track_media(base, token, rating_key)
-> player.play_data(bytes, id), mirroring the local-file seam. Bit-perfect:
play_data drives device init from the DECODED bytes; Plex metadata is
display-only. Covers album play, single track, and auto-advance.
cc
Albums tab only (Artists + flat Tracks tab remain Plex-blind — next slice):
- Albums loader switches to get_albums_metadata_page(.., Some(plex_cache_path))
so the plex_aggregated union surfaces Plex albums alongside local; gated on
plex_settings enabled (None path = local-only, no regression). seed_counts
uses the page total so the badge includes Plex.
- Album-detail branches group_key "plex:" -> qbz_plex::plex_cache_get_album_tracks,
mapped to LocalTrack (rating_key in file_path, source=plex, album name into
album_group_title for the detail header).
- Source-aware grid + album-detail-header covers via spawn_local_or_plex_loads:
a /library|/photo path with creds becomes ArtworkRef::PlexThumb, else LocalFile.
Queue/now-playing/MPRIS artwork untouched (that is slice 4).
- Truthful module/Albums-tab docs.
cc
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
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
Lift the in-memory ephemeral-folder library (synthetic-id store + folder
scan / metadata / CUE handling) out of the Tauri binary into a
frontend-agnostic qbz-library module (ADR-006), so both the Tauri and Slint
frontends consume identical logic. src-tauri/ephemeral_library now re-exports
it as a thin shim. Adds tracks_snapshot() / current_folder_path() for the
Slint consumer.
cc
Port the Tauri desktop-notification path to a frontend-agnostic crate so the
Slint app (and any future TUI/CLI) fires it from native Rust instead of a
webview invoke.
New qbz-media-controls::notify module:
- Linux: XDG notification portal via ashpd (D-Bus); album art passed as
Icon::Bytes after center-crop + downscale to <=512px + PNG re-encode (<=4 MiB).
- macOS: notify_rust with image_path (cover cached to disk).
- Windows: no-op (parity with Tauri).
- Self-contained artwork download + md5 cache + resize, matching the Tauri
helpers, with no dependency on the frontend (ADR-006).
Wired in playback::refresh_now_playing_meta (the same point that pushes the
tray tooltip + OS media controls):
- De-dupe by track id so only an actual track change fires (the refresh also
runs on resume/seek).
- Skip while a remote QConnect renderer drives playback (matches the Svelte
skipIfRemote). Fire-and-forget on a tokio task.
Enabled by default, matching the Tauri default; a settings toggle is a
follow-up.
cc
Replace the tray-icon/muda macOS backend with a hand-rolled NSStatusItem +
NSMenu built directly on objc2 0.5 / app-kit 0.2 (the same objc2 generation
winit 0.30 uses), so the menu lives in winit's NSApplication runtime and the
target-action dispatches. tray-icon's bundled muda collided with the muda
Slint's winit backend already registers and never fired the menu items. This
drops the tray-icon dependency and its whole GTK tree from the lockfile.
- Menu-bar icon pinned to 18pt via NSImage::setSize; the 44px PNG was
rendering at native pixel size (giant icon).
- The status button gets its own target-action firing on left AND right
mouse-up: left-click toggles the window, right/control-click pops the menu
transiently (setMenu -> performClick -> clear, the non-deprecated
replacement for popUpStatusItemMenu:). The menu is no longer permanently
attached, so a left-click no longer opens it.
- Add the NSEvent feature to objc2-app-kit.
cc
Port the Tauri normalizeCoverUrlForMetadata workaround as ArtworkRef::to_mpris_url
(qbz-models): remote Qobuz covers pass through as-is, local-file covers become a
percent-encoded file:// URI (MPRIS clients cannot read bare paths or asset://),
Plex thumbs become a tokenized HTTP URL. Wired into the Slint now-playing push so
the media widget shows album art. The app icon was already fixed via DesktopEntry.
cc
New frontend-agnostic crate qbz-media-controls (ADR-006): one MediaIntegration
trait, cfg-split backends. Linux uses mpris-server and publishes the MPRIS
org.mpris.MediaPlayer2.DesktopEntry property as com.blitzfc.qbz — the only way
GNOME Shell resolves the app icon for its media widget (souvlaki, the old
cross-platform crate, never set it, so GNOME showed no icon; KDE is lenient).
macOS/Windows keep souvlaki (MediaRemote/SMTC), where the icon comes from the
app bundle, not a DesktopEntry. The Linux server runs on a dedicated thread
with its own tokio runtime; state arrives over an async channel.
Wired into qbz-slint via src/media_controls.rs: started on shell entry,
metadata/playback pushed from the playback paths (mirroring the tray), and
inbound control events (media keys, the desktop media widget, Now Playing)
dispatched to the player through the existing QConnect-aware helpers. Both
Linux and macOS compile clean.
cc