Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh f6177577c8 feat(slint): QConnect offline gate (D5) and slice-3 main wiring
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
2026-06-09 21:00:33 -06:00
vicrodh 727c3fb4ba feat(slint): disk-first cached favorites, read-only hearts offline
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
2026-06-09 21:00:33 -06:00
vicrodh dee85faadb feat(slint): offline artwork policy and local media-controls art
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
2026-06-09 21:00:33 -06:00
vicrodh b4caa5a309 feat(slint): Settings Offline Mode group (tri-state status, induced toggle, network folders)
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
2026-06-09 20:46:05 -06:00
vicrodh 38e0b4be27 feat(slint): offline status UI plumbing, login affordances, recovery banner
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
2026-06-09 20:31:07 -06:00
vicrodh 904b5f7b40 feat(slint): real offline session entry, token-survival fix, grace producers
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
2026-06-09 20:14:35 -06:00
vicrodh 35403028da fix(qobuz): correct stale doctest import path in performers
The doc example still imported crate::api::performers from the pre-extraction
src-tauri layout; the doctest never compiled in the extracted crate.

cc
2026-06-09 20:14:35 -06:00
vicrodh ba702bc3b8 feat(qobuz): route every client HTTP request through the offline gate
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
2026-06-09 19:55:37 -06:00
vicrodh edae9d2463 feat(app): shared offline-mode engine with connectivity actor and grace gate
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
2026-06-09 19:48:13 -06:00
vicrodh 10b13be6e3 fix(myqbz): detail row ⋯ menu was unclickable — open-item TouchArea sat on top
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
2026-06-09 15:45:24 -06:00
vicrodh 8b0df8325d fix(myqbz): context menus — z-index, Remove/queue wiring, sidebar + album->collection
- 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
2026-06-09 15:17:03 -06:00
vicrodh 47fdfe6622 fix(myqbz): centre the detail ToolBtn glyph (Filter / Select)
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
2026-06-09 14:41:03 -06:00
vicrodh ea517326b7 fix(myqbz): visual round 2 — local art, checkbox-filter, circular checkboxes, quality skeleton, album header
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
2026-06-09 13:31:31 -06:00
vicrodh 0b20a1a86a fix(myqbz): visual homologation — app components, colors, scrollbar, resolveItems
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
2026-06-09 12:35:03 -06:00
vicrodh 838085a492 fix(myqbz): per-collection view-prefs persistence (§18) + shuffle persists play_mode
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
2026-06-09 09:08:05 -06:00
vicrodh 4e0d1a005e fix(myqbz): final fidelity pass (uppercase, trim, expanded cache, bulk bar §13.1)
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
2026-06-09 08:49:39 -06:00
vicrodh 16e6232220 refactor(myqbz): button audit — app primitives + fix glyph centering
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
2026-06-09 08:29:11 -06:00
vicrodh 6cbd51427d feat(myqbz): detail expanded-mode inline tracks + headless skip-to-item
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
2026-06-09 08:09:31 -06:00
vicrodh 0bffd32555 feat(myqbz): My QBZ rename/change-icon in Settings>Appearance (DQ3)
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
2026-06-09 07:40:44 -06:00
vicrodh c6aa61a09e feat(myqbz): Discography Builder — artist_collection creation flow
Phase-2 Slice 11. The third MyQBZ kind (artist_collection / artist_discography),
1:1 with spec 13 (the 1445-line DiscographyBuilderView):
- ui/myqbz/DiscographyBuilderView.slint: header (artist avatar + name + eyebrow),
  name input, 3-segment order-by, the 7-col grouped release table
  (28 56 72 1fr 72 60 156) with tri-state select-all, primary + alternate rows
  (↳ connector, source indicator, QualityBadge, per-row type-override popover),
  footer (count + Create). Exact §0 dark colors + type tints.
- src/myqbz_builder.rs: candidate/group model + normalize/classify/quality-score;
  THREE sources fully wired (Qobuz get_artist_page + local + Plex via the unified
  metadata query); build_groups dedupes/buckets/quality-sorts (Qobuz wins ties);
  persisted release-type override sidecar. Save = create_collection (ArtistCollection,
  ArtistDiscography, source_ref=artist_id) + add_item per checked candidate in sort
  order (Plex->local) -> navigate to the new collection's detail.
- Entry: the artist page "Build Artist Collection" button (was unwired).

Known polish nits (S12): view-wide uppercase eyebrow/labels; Create should disable
on whitespace-only name. Toast uses real strings (toast.collectionCreated/Failed are
absent from en.json; DQ4). Backend crates untouched. cargo check -p qbz-slint green.

cc
2026-06-09 07:19:06 -06:00
vicrodh 1db40d87ef feat(myqbz): DJ-mix modal + detail multi-select bulk bar
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
2026-06-09 06:42:39 -06:00
vicrodh 5da3b8bfd5 feat(myqbz): AddToMixtapeModal + app-wide add-to-collection entry points
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
2026-06-09 06:19:35 -06:00
vicrodh 2a72878a1d feat(myqbz): detail edit — overflow menu, modals, play-mode/kind/cover
Phase-2 Slice 7. The hero overflow (⋯) menu and the edit operations:
- Hero action row corrected to spec §3.6: exactly 4 controls (Play primary,
  filled white disc + black glyph; Shuffle; DJ-mix [turntable glyph]; ⋯ overflow)
  -- replaces the S3 flat-circles deviation.
- ⋯ overflow menu (spec §10): Rename, Edit description, Upload/Clear custom cover,
  play-mode toggle (label = the OTHER mode), Convert kind (hidden for
  artist_collection), Delete (destructive).
- ui/myqbz/MyQbzEditModal.slint: Rename / Description / Delete-confirm modals
  (mirrors EditPlaylistModal chrome).
- src/myqbz_edit.rs: rename / set_description / toggle play_mode / convert kind /
  delete via with_db -> with_connection -> qbz_mixtape::repo::*; each reloads the
  detail, delete navigates back. "Converted"/"Cannot convert this kind" toasts.
- src/myqbz_cover.rs: custom-cover upload (rfd picker, ext validate, resize
  1000x1000 Lanczos3, save to artwork cache, persist-before-delete-prev) + remove.

dj-mix + sync stay stubs (later slices). Authoritative copy (Upload cover / Clear
custom cover / In order / double-quote delete). Backend crates untouched.
cargo check -p qbz-slint green.

cc
2026-06-09 04:38:35 -06:00
vicrodh b049b1cd5c feat(myqbz): detail playback — resolve collection + drive the queue
Phase-2 Slice 5. Wires the detail view's hero Play/Shuffle and per-row play
actions to the qbz-mixtape enqueue resolver:

- src/myqbz_play.rs: builds ProdItemResolver (qobuz client snapshot-cloned + a
  Send+Sync local closure that runs with_db SYNCHRONOUSLY, so &LibraryDatabase
  never crosses an .await), resolve_collection_tracks, drives the qbz-core queue.
- Hero Play = replace per the collection play_mode + stamp queue-source +
  touch_play. Hero Shuffle = force album_shuffle. Per-row Play/Play-next/
  Add-to-queue = single-item resolve with inline source_item_id_hint, no
  queue-source stamp/touch_play; play-next inserts in reverse. Failing items
  logged+skipped; empty resolve toasts "0 playable tracks".
- Un-stubbed on_play_all/on_shuffle/on_play_item/on_item_action; dj-mix/edit/
  delete/sync stay stubs (later slices). skip-to-item deferred.

Backend crates untouched. cargo check -p qbz-slint green.

cc
2026-06-09 04:08:31 -06:00
vicrodh be832324bd feat(myqbz): create-new Mixtape/Collection modal + wiring
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
2026-06-09 03:57:30 -06:00
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 5458f3aa74 feat(mixtape): extract shared qbz-mixtape crate (MyQBZ backend, headless)
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
2026-06-09 02:16:20 -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 1409c9d917 feat(plex): split same-title Plex albums into selectable versions
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
2026-06-08 18:28:15 -06:00
vicrodh ca4b43d635 fix(slint): route Plex/local album opens to the LocalAlbum view
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
2026-06-08 17:38:37 -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 b37c29af14 feat(slint): Plex progressive streaming (fast play) + fix re-sync wiping hydrated quality
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
2026-06-08 14:39:05 -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 48a78192c0 chore: sync src-tauri Cargo.lock with qbz-models urlencoding dep
The Plex artwork-perf slice added urlencoding/url to qbz-models; the lockfile
sync only landed in crates/Cargo.lock. src-tauri depends on qbz-models, so its
lockfile needs the same entries (no behavior change).

cc
2026-06-08 10:27:28 -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 a680da5ad8 perf(slint): server-transcoded Plex thumbs + shared HTTP client for images
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
2026-06-08 09:03:37 -06:00
vicrodh 381c0b1707 feat(slint): source-aware Plex artwork on now-playing bar, queue panel, MPRIS (port slice 4)
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
2026-06-08 08:32:06 -06:00
vicrodh 334ddf315d feat(slint): Plex artists + tracks in Local Library Artists/Tracks tabs (port slice 3c)
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
2026-06-07 23:43:34 -06:00
vicrodh 06b2bea932 feat(slint): Plex track playback (port slice 5)
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
2026-06-07 22:39:19 -06:00
vicrodh a45840d178 feat(slint): Plex albums in Local Library grid + detail + covers (port slice 3a/3b)
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
2026-06-07 22:39:19 -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 ee9a3c1c89 refactor(qbz-library): extract ephemeral-folder module to the shared crate
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
2026-06-07 10:12:44 -06:00
vicrodh d08c5f6569 feat(slint): system "now playing" notifications on track change
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
2026-06-06 16:45:04 -06:00
vicrodh b9434a5034 fix(slint): hand-rolled macOS tray menu (objc2 0.5) with correct icon size and click routing
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
2026-06-06 16:43:38 -06:00
vicrodh 2a2328e6c6 feat(slint): MPRIS album art (mpris:artUrl) — Qobuz pass-through + local file://
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
2026-06-06 11:58:17 -06:00
vicrodh ee8700db1a fix(packaging): Arch installs com.blitzfc.qbz.desktop for the MPRIS app icon
GNOME resolves the MPRIS media-widget app icon via DesktopEntry -> the installed
<id>.desktop -> Icon=. Rename the Arch desktop file to com.blitzfc.qbz.desktop,
set Icon=com.blitzfc.qbz, and install the hicolor icon as com.blitzfc.qbz.png so
the basename matches the published DesktopEntry. Other channels (AUR/Gentoo/Snap
+ Tauri bundle/release workflow) documented in qbz-nix-docs (release-time,
build-test required). Flatpak was already correct.

cc
2026-06-06 11:03:34 -06:00
vicrodh 7a7e5f82c4 feat(slint): system media controls — MPRIS with GNOME app icon + SMTC/MediaRemote
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
2026-06-06 11:03:11 -06:00
vicrodh 682e735dc2 fix(slint): macOS tray menu — force Regular activation policy (UNVERIFIED)
Attempt to fix macOS tray menu items doing nothing: a bare `cargo run`
binary is not a bundled .app, so macOS does not dispatch NSStatusItem
menu-item target-actions (the menu shows, items enabled, but clicks fire
nothing). Force the app to a Regular, active application at tray creation
via objc2-app-kit setActivationPolicy(.regular) + activate(), and switch
menu handling to MenuEvent::set_event_handler with a diagnostic log.

NOTE: NOT verified at runtime on macOS yet (pending the user's GUI test). If
the menu still does nothing, look for the '[tray] menu item activated' log
line to tell whether the handler fires.

cc
2026-06-06 09:28:21 -06:00
vicrodh a91d629c2b feat(slint): macOS/Windows system tray (tray-icon) + macOS Dock policy
Add the NSStatusItem (macOS) / Shell_NotifyIcon (Windows) tray via the
tray-icon crate: the same 5-item menu, left-click window toggle, a themed
icon (auto uses a macOS template image that adapts to the menu bar), a
static QBZ tooltip, and live icon-variant switching. tray_icon::TrayIcon is
!Send and (on macOS) must be built on the main thread with NSApplication
running, so it lives in a thread_local created via invoke_from_event_loop;
its events are drained from a slint::Timer.

macOS close-to-tray reuses the cross-platform Slint hide/show path. The
opt-in Hide Dock icon toggle switches the NSApplication activation policy to
.accessory while closed to the menu bar (.regular by default, Spotify-style)
via objc2-app-kit.

cc
2026-06-06 02:15:40 -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 5b3ba963b0 feat(settings): tray store gains mac_hide_dock + close-to-tray opt-out
Add a mac_hide_dock column (with migration) to the shared tray settings
store for the upcoming macOS close-to-tray Dock-icon toggle, and flip the
close_to_tray default to true (opt-out: closing sends the window to the
tray and keeps the app running).

cc
2026-06-06 01:44:30 -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 02fdd94c46 fix(qconnect): take the render on idle connect (normalize -1 active id)
The take-renderer-when-idle path never fired: capture_session_state_takeover_input
parsed active_renderer_id raw. The cloud encodes "nobody active" as -1, which was
read as Some(-1) and classified as a peer being active (OtherPaused), so
compute_connection_state returned should_set_active_renderer: false and the
auto-take was suppressed. Normalize identically to the apply path
(normalize_active_renderer_id filters >= 0 -> None) so -1 maps to
ServerActiveState::None and the idle auto-take claims the render. Adds the
auto_take_when_idle decision to compute_connection_state plus regression tests
for the -1 / active-peer classification.

cc
2026-06-05 21:09:26 -06:00
vicrodh 1287d4feaf fix(qbz-slint): cold-start current track on play when no audio is loaded
Local toggle_play always called resume(), which fails with "cannot resume -
no audio data available" when the player holds no loaded stream — e.g. a
freshly materialized QConnect renderer queue whose cursor sits on a track that
was never loaded. Detect the no-loaded-audio case via Player::has_loaded_audio()
and load+play the current queue track instead of resuming. A normal pause keeps
the stream loaded, so the pause/resume path is unchanged.

cc
2026-06-05 21:09:14 -06:00
vicrodh b4c3bab91b fix(qconnect): WS-authoritative shuffle/repeat gating, button reflection, pause-to-iOS
Shuffle and Repeat now go to the CLOUD whenever connected (renderer OR controller),
gating on transport_connected like Tauri's v2_toggle_shuffle / v2_set_repeat_mode,
instead of the old peer-active gate that fell through to a LOCAL reshuffle when QBZ
was the active renderer — that did nothing visible AND risked a divergent local
order. QBZ sends only the seed + pivot; the cloud owns the order; the toggle reads
the cloud-authoritative queue.shuffle_mode. The local shuffle path is now reachable
only when NOT connected.

- shuffle/repeat button reflects the cloud queue flag (queue.shuffle_mode for the
  controller branch, core queue state via refresh_transport_modes for the renderer
  branch), not the per-renderer field the cloud never populates for a peer — fixes
  the iOS-reflects / WebPlayer-doesn't asymmetry.
- pause/play to a peer renderer: send a BARE SetPlayerState (playing_state only).
  iOS reports current_queue_item_id=null while playing, so resolving the qid landed
  on queue_item_id 0 — the protocol's "unknown" placeholder — and iOS could not
  anchor the pause, auto-resuming. A bare transport toggle is spec-sanctioned: the
  renderer pauses/resumes its own current item in place.
- skip_serializing_if on the SetPlayerState request structs so a None field is
  OMITTED, not serialized as null; and the mapper now treats a null current_queue_item
  as absent. Without this the bare command failed to encode and was never sent.
- log the outbound SetPlayerState payload for controller diagnostics.

cc
2026-06-05 17:44:11 -06:00
vicrodh 1072195740 merge: bring v1.2.15 Tauri fixes (shared HTTP client / fd exhaustion, qobuz bundle-token cache + bounded fetch) into slint-mvp
cc
2026-06-05 15:11:48 -06:00
vicrodh 31a4023f0b chore: bump Nix flake hashes for v1.2.15
cc
2026-06-05 13:01:58 -06:00
vicrodh 5b551561f7 chore(release): bump to 1.2.15
Version bump across Cargo/tauri/npm/snap/flake, metainfo release entry,
and TAG_DETAILS for 1.2.15. flake.nix hashes cleared (filled post-tag
via nix build once v1.2.15 exists).

cc
2026-06-05 01:10:03 -06:00
vicrodh adf0961f9d fix(images): reuse a shared HTTP client to stop fd exhaustion
Discover and other image-heavy views created a fresh
reqwest::blocking::Client per image (and per notification artwork),
exhausting the process file-descriptor limit and crashing the app with
"Too many open files". Reuse a single shared client across downloads.

cc
2026-06-05 01:09:58 -06:00
vicrodh 0cefe1ee80 fix(qobuz): cache bundle tokens and bound the fetch to stop startup hangs
Startup blocked on Qobuz's ~7 MB web bundle download with no cache or
timeout, so a slow CDN left the app on a blank screen for 25-30s before
the login screen appeared. Cache the extracted tokens on disk (keyed by
bundle version), apply them instantly on warm starts and refresh in the
background, and bound the cold fetch with a per-request timeout + retry.
The legacy api client shares the same cache. Emits BundleFetchStarted so
the UI can show a connecting state on the first run.

cc
2026-06-05 01:09:54 -06:00
vicrodh 1f1d7303ef fix(qconnect): badge clear, multi-track enqueue, shuffle WS-authoritative, cursor/history/pause
- badge: gate is-remote on transport_connected so it clears on every session-end
  path (transport drop / watchdog / reconnect-exhausted), not only toggle-off
- multi-track: route a whole album/playlist/favorites enqueue to the peer in one
  QueueAddTracks/QueueInsertTracks (all-or-nothing admission), not single-track only
- shuffle inbound: SetShuffleMode renderer arm is flag-only (set_shuffle_flag) so the
  renderer never invents a local order; the cloud's echoed order is authoritative
- shuffle outbound: send shuffle_seed + shuffle_pivot_queue_item_id (the cloud needs
  the seed to build the order); hoist resolve_qconnect_shuffle_pivot to qconnect-app
- start index: resolve_remote_start_index validates the track_id before trusting a
  qid, so a freshly-pushed album no longer inherits a stale cursor (wrong NowPlayingBar track)
- history: play_index records history only when actually moving, so the current track
  is no longer duplicated in the History tab
- pause-to-peer: resolve current_queue_item from the core-aligned cursor instead of the
  stale renderer snapshot, so pausing an iOS renderer (reports null qid) is accepted

cc
2026-06-04 21:59:58 -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 8e1bf18f2f feat(qconnect-app): resume Qobuz Connect takeback at the handed-off position
When QBZ becomes the active renderer (takeback), force a stream of the
current track and resume at the peer's position instead of failing or
restarting from 0:

- add QconnectRendererEngine::has_loaded_audio so the orchestration can
  tell a stale track_id left by stop() from audio that is actually
  buffered. stop() clears the buffer but leaves current_track_id, so the
  track-id guard alone skipped the reload and the next resume hit
  no audio data available.
- force_remote_track_stream on SetActive(true): reload even when the
  track id matches (unless already streaming it), at the handed-off
  position.
- thread the SetState position into ensure_remote_track_loaded so the
  first takeback of a session (whose load lands in the SetState path)
  also resumes at the peer's position rather than streaming from 0 and
  issuing a forward seek the audio thread ignores past the watermark.

cc
2026-06-03 22:13:35 -06:00
vicrodh ab44debb75 fix(qbz-slint): don't auto-steal Qobuz Connect render on fresh connect
deferred_renderer_join joined every connect with is_active=true, so QBZ
grabbed playback from whatever peer was rendering the instant it came
online, and the self-state echo from the post-join AskForRendererState
then reset the cursor to the queue head. Join as an available renderer
(is_active=false) on a fresh connect; only a post-drop RECONNECTION
rejoins as active, so a network blip mid-render does not lose the render.
Taking over is now explicit (device-picker Play here, or the phone
selecting QBZ, both arriving as SET_ACTIVE) - matching the official
clients.

cc
2026-06-03 22:13:26 -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 97c232b92a fix(qconnect): make SetVolume a supersedable pending action
Add `is_set_volume_action` to PendingQueueAction (set for CtrlSrvrSetVolume) and
complete it on the SrvrCtrlVolumeChanged echo, so rapid volume changes can
supersede the in-flight one instead of colliding on the single pending slot (the
"pending queue action already active" spam). The frontend adapter clears the
superseded pending before sending the next SetVolume (lands with the qbz-slint
controller changes).

cc
2026-06-03 17:28:37 -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 a1b054c387 feat(qbz-slint): reflect peer volume on the bar with a 50% safety clamp
When controlling a peer renderer, the volume slider must reflect the PEER's
actual volume, never QBZ's local 100, so a drag starts from a safe level and
never nukes the AVR. Extend RemoteNowPlaying with the peer's reported volume
(sourced from the effective remote renderer snapshot) and, in the poll loop's
peer-reflection branch, push it to NowPlayingState.volume as
v/100 (clamped 0..1), falling back to 0.5 (50%) when the peer's volume is
unknown. This branch only runs when a peer is active, so local/renderer
playback is unaffected.

cc
2026-06-03 10:46:30 -06:00
vicrodh 4f0e7a5664 feat(qbz-slint): route local play to the peer renderer when controlling
Playing a new album/track on QBZ while controlling a peer renderer played
it LOCALLY instead of casting it to the peer. Add
SlintQconnectService::play_on_peer_if_active(track_id): gated on
is_peer_renderer_active, it pushes the current core queue to the peer
unconditionally (same all-or-nothing Qobuz-castable admission as
sync_local_queue_if_changed; a mixed/local/Plex queue is refused with a
toast and treated as handled so it is not double-played locally), then
calls play_remote_renderer_track_if_active to SetPlayerState the peer to
the requested track. Send errors are logged but still return handled — a
peer owns playback, so never fall back to local audio (double-play).

play_audible (the single local-play chokepoint for play-all / track play)
now tries play_on_peer_if_active first and returns early when handled. When
no peer is active it returns false and the existing local play runs
byte-unchanged, so renderer / local playback do not regress.

cc
2026-06-03 09:27:00 -06:00
vicrodh 6405b10204 feat(qbz-slint): reflect peer renderer playback on the now-playing bar
When QBZ is CONTROLLING a peer renderer the local player is stopped, so
the poll loop saw track_id == 0 and the seek bar froze. Add
RemoteNowPlaying + SlintQconnectService::remote_now_playing(), sourced
from effective_remote_renderer_snapshot (peer position / updated_at /
playing). The poll loop now branches at the top of each tick: when a peer
is active it drives position/duration/progress/playing from the peer
(duration from the core queue's current track, position extrapolated and
clamped), resets the local edge trackers, and skips the local body. In
every non-controller situation remote_now_playing returns None and the
existing local path runs byte-unchanged, so renderer mode does not regress.

cc
2026-06-03 09:23:08 -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 126e9da1db feat(qbz-slint): gate transport callbacks through QConnect controller handoff
Wire the eight NowPlayingState transport callbacks (toggle_play, next,
previous, seek, set_volume, toggle_mute, toggle_shuffle, cycle_repeat) with the
"try remote -> fall back to local" pattern: each spawns a task that calls the
matching *_if_remote method; Ok(true) short-circuits (forwarded to the active
peer), Ok(false)/no-service falls through to the existing playback::* local
call unchanged. Includes the seek fraction->ms (via local duration), volume
0..1->0..100, and mute-negation conversions.

Adds playback::is_muted() to read the authoritative local mute flag for the
mute handoff. No regression to renderer/local playback: the remote gate is
is_peer_renderer_active, so every non-controller branch returns Ok(false).

cc
2026-06-03 06:49:03 -06:00
vicrodh 207abb8be0 feat(qbz-slint): QConnect controller transport routing (*_if_remote)
Port the controller-mode transport seam from the Tauri adapter onto
SlintQconnectService: effective_active/remote_renderer_snapshot, the prime_*
optimistic-state helpers, send_command (with the superseded-pending clear),
and all *_if_remote methods (toggle_play, skip next/previous, play_track,
toggle_shuffle, cycle_repeat, set_volume, mute, set_autoplay_mode,
autoplay_load_tracks, stop, set_position) plus set_active_renderer. Each gates
on a peer renderer being active and returns Ok(false) to fall back to local.

Adds the controller request DTOs (set-player-state/volume/mute + the pure
build_set_position_player_state_request) to qconnect_transport, mirroring the
Tauri types byte-for-byte. Diagnostics route through log + dev_push_event;
cursor alignment uses the core's silent sync_current_to_id. Not yet wired to
the transport callbacks (Slice 2) or the device picker (Slice 3); dead-code
warnings are expected until then.

cc
2026-06-03 06:29:07 -06:00
vicrodh 82b7ce79fb refactor(qconnect-app): hoist build_effective_renderer_snapshot for cross-frontend reuse
Move the pure build_effective_renderer_snapshot helper from the Tauri adapter
into qconnect-app/src/session.rs (next to build_session_renderer_snapshot),
export it from the crate root, and repoint the Tauri side to the re-export.
Byte-identical behavior; enables the Slint controller path to reuse it.

cc
2026-06-03 00:41:50 -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 3861efa79f refactor(qconnect-app): hoist renderer-projection helpers from the Tauri adapter
Move build_session_renderer_snapshot, queue_item_snapshot_for_cursor and
cache_renderer_snapshot into qconnect-app (session.rs / sync_state.rs) and
re-export them; repoint the Tauri qconnect adapter to consume them. This lets
the Slint adapter share one definition (ADR-006) instead of duplicating the
projection logic. Behavior is byte-identical.

cc
2026-06-03 00:11:21 -06:00
vicrodh 8e61568dc0 feat(qbz-slint): QConnect transport config + credential discovery (slice 6, phase S)
Ports the Tauri qconnect/transport.rs config layer to Slint: resolve_transport_config
(env overrides -> qws/createToken auto-discovery -> WsTransportConfig with require_jwt
+ 60s idle-retry + default channels) and fetch_qconnect_transport_credentials (POST
/qws/createToken with X-App-Id / X-User-Auth-Token), plus the device-identity helpers
(uuid via shared qbz_app::qconnect_identity, hostname, friendly name, persisted
device-name in qconnect_settings.db) and the hex-channel parsers.

Frontend difference handled: the Qobuz client is reached via runtime.core().client()
(Arc<RwLock<Option<QobuzClient>>>, may be None before API init) and is gated/ok_or'd,
unlike Tauri's always-present AppState.client. Uses qbz_qobuz::endpoints::build_url
(same base) instead of the Tauri-local one.

Deps added: qbz-qobuz (build_url), qconnect-transport-ws (WsTransportConfig /
NativeWsTransport). allow(dead_code) until the service wires it. cargo check
-p qbz-slint green (only the pre-existing unrelated rename_folder warning).

cc
2026-06-02 20:52:51 -06:00
vicrodh 88e72c3e61 feat(qbz-slint): add QConnect renderer engine over QbzCore (slice 6, phase S)
Phase S steps 9-10. Makes qbz-Slint a QConnect renderer: SlintRendererEngine
implements qconnect_app::QconnectRendererEngine over runtime.core(), so the Slint
frontend inherits the shared echo/cursor/materialize/shuffle orchestration in
qconnect_app::renderer instead of re-deriving it.

- Deps added to qbz-slint: qconnect-app, explicit qbz-player, futures-util, uuid.
- 16 trait forwards through runtime.core() (CoreError mapped to String) +
  current_output_format reading player().state.
- start_track_stream + the HTTP feeder (probe FLAC -> play_streaming_dynamic,
  fallback download -> play_data) ported from the Tauri CoreBridge impl. The
  protected bit-perfect seams are only called, never modified; the probe-derived
  sample_rate/channels/bit_depth flow straight through.
- TLS deviation: the crates-workspace reqwest ships rustls-tls (not native-tls),
  so .use_native_tls() is dropped. TLS is transport-only; audio bytes identical.

allow(dead_code) until the Slint QConnect service + event sink wire it (next
Phase S step). Verified: cargo check -p qbz-slint green (only a pre-existing
unrelated rename_folder dead-code warning).

cc
2026-06-02 20:27:44 -06:00
vicrodh 37cd1b154b test(qconnect-app): mock-engine tests for echo/cursor/materialize (slice 6)
Step 8 of the renderer-engine extraction — the payoff of sharing. A recording
MockRendererEngine drives the renderer orchestration end-to-end, proving the
hard-won behavior is engine-independent (any future Slint regression is a wiring
bug in its trait impl, not a re-derivation bug in the shared logic). This logic
previously could only be exercised through the Tauri adapter.

Six scenarios:
- load dedup window: two ensure_remote_track_loaded for the same track within
  5s -> start_track_stream once; no reload when track_id unchanged.
- #387 echo-seek: same-track SetState at <=1s while local is ahead -> seek
  rejected; a genuine far seek IS honored.
- stale-projected-track guard: state-only SetState (command.current_track=None)
  -> no align / no load from renderer_state's stale track; pause still applied.
- shuffle deferral: first event (shuffle_mode=true, order=None) -> shuffle_enabled
  false (no invented identity order); second event with order -> enabled.

qconnect-app 38 -> 44 tests; all green, no warnings.

cc
2026-06-02 19:46:41 -06:00
vicrodh a2d0df8b30 refactor(qconnect): hoist audio-quality report dedup into qconnect-app (slice 6)
Step 7 of the renderer-engine extraction. Moves report_file/device_audio_
quality_if_changed (dedup-against-last-reported + build RendererReport + send)
out of the Tauri service into QconnectApp methods, so the Slint report-back loop
reuses them. The Tauri service methods are now thin delegates that extract
runtime.app and forward.

Byte-identical: runtime.sync_state and the Arc passed to QconnectApp::new (the
one sync_handle() returns) are clones of the SAME Mutex (service.rs:232/242/277),
so the dedup state and the report build/send are unchanged; only the owner of
the code moved. send_renderer_report_command + the last_reported_* fields were
already in qconnect-app.

Verified: cargo check -p qbz-nix clean (no warnings); qbz-nix qconnect 42/42 +
qconnect-app 38/38 green.

cc
2026-06-02 19:39:25 -06:00
vicrodh b21d45c2f6 refactor(qconnect): hoist renderer orchestration behind engine trait (slice 6)
Step 6 of the renderer-engine extraction. Moves the renderer orchestration
out of the Tauri adapter UP into frontend-agnostic qconnect-app, rewritten
against &impl QconnectRendererEngine instead of &CoreBridge, so the Slint
adapter reuses the hard-won echo/cursor/materialize/shuffle logic instead of
re-deriving it.

Moved into qconnect_app::renderer (byte-for-byte, bridge -> engine):
apply_renderer_command (incl. the #387 echo-seek gate, the stale-projected-
track guard, force-restart-removed reasoning), materialize_remote_queue (skip-
gate, dedupe+batch hydrate, 3-tier start-index, shuffle deferral
effective_shuffle_enabled = shuffle_mode && order.is_some(), cursor-align +
stale-local-stop), sync_remote_shuffle_projection, align_queue_cursor,
apply_remote_loop_mode, ensure_remote_track_loaded (+ should_reload /
is_recent / queue_state_needs_materialization, LOAD_ATTEMPT_DEDUP_WINDOW,
PLAYING_STATE_* consts). The sync_state lock-drop-across-await discipline is
preserved; last_load_attempt is still recorded before the load.

Stays adapter-side: corebridge.rs is now thin wrappers that unwrap the
Arc<RwLock<Option<CoreBridge>>> guard and delegate (event_sink/service call
sites unchanged). In operation the bridge is populated at startup before any
QConnect session opens, so the guard-before-skip reorder is unreachable;
behavior is byte-identical. ensure_remote_track_loaded now resolves duration
via get_track then calls engine.start_track_stream (which owns get_stream_url +
the protected feeder); the two independent catalog reads swap order, benign.
mod.rs re-exports PLAYING_STATE_* from qconnect-app.

Verified: cargo check -p qbz-nix clean (no warnings); qbz-nix qconnect 42/42 +
qconnect-app 38/38 green; Opus adversarial review CLEAN across byte-identity,
guard-reorder, lock-discipline, ensure-reorder, Send/generics, re-exports.

cc
2026-06-02 18:59:34 -06:00
vicrodh e8ac6185b6 feat(qconnect): impl QconnectRendererEngine for CoreBridge (slice 6)
Step 5 of the renderer-engine extraction. Adds the Tauri-side trait impl
so CoreBridge becomes a QConnect renderer engine; the hard-won orchestration
(echo gate, materialize, dedup) will be hoisted above it in step 6.

Every transport/queue/catalog method is the same one-line forward CoreBridge
already does to QbzCore, spelled CoreBridge::method(self, ..) to call the
inherent method unambiguously and never recurse into the trait impl. The audio
thread sees identical bytes.

start_track_stream is the verbatim load_remote_track_into_player +
stream_remote_track_into_player body with quality/duration resolution lifted to
the caller; the protected seams (play_streaming_dynamic / play_data) and the
HTTP feeder stay impl-side and are only called, never modified. Threaded
start_position_secs through stream_remote_track_into_player (existing caller
passes 0, byte-identical) so the trait param is honored for non-QConnect callers.

current_output_format reads player().state.{get_sample_rate,get_bit_depth} for
the report-back path.

Verified: cargo check -p qbz-nix clean; qbz-nix qconnect 42/42 +
qconnect-app 38/38 green; Opus adversarial review CLEAN (recursion-safety,
byte-identity, protected-seam-untouched, type unification).

cc
2026-06-02 17:44:36 -06:00
vicrodh 76a9169e2f refactor(qconnect): hoist queue-track / volume / loop-mode mappers into qconnect-app (slice 6)
Move the pure protocol mappers model_track_to_core_queue_track,
normalize_volume_to_fraction, qconnect_repeat_mode_from_loop_mode (+ the
QCONNECT_REMOTE_QUEUE_SOURCE tag) into qconnect-app/src/renderer.rs so the
renderer orchestration that calls them (hoisted in a later step) can live there
too. The src-tauri mod.rs re-exports them, so corebridge + tests compile
unchanged. The load-dedup predicates and audio-quality report helpers move
alongside their orchestration / report consumers in the next steps (cohesion +
avoids churning the AUDIO_QUALITY_* consts now).

src-tauri qconnect 42 green.

cc
2026-06-02 17:16:41 -06:00
vicrodh 1133b8b45d refactor(qconnect): hoist queue_resolution into qconnect-app (slice 6)
queue_resolution.rs is pure protocol math (queue_item_id/track_id/order cursor
reconciliation) over QConnectQueueState/QConnectRendererState only -- no engine,
no Tauri. Relocate it to qconnect-app so the renderer orchestration that calls it
(materialize / cursor-align, hoisted in a later step) can live there too. The
src-tauri module becomes a `pub(super) use qconnect_app::queue_resolution::*;`
re-export shim, so every `super::queue_resolution::…` call site + the existing
tests compile unchanged. Visibility widened pub(super)->pub for the re-export.

src-tauri qconnect 42 green (tests now exercise the moved code via the shim),
qconnect-app check green.

cc
2026-06-02 17:06:28 -06:00
vicrodh e5e8d4a853 feat(qconnect-app): add QconnectRendererEngine trait + qbz-player dep (slice 6)
The renderer-engine seam: a 16-method trait both frontends implement with
zero-cost forwards to their QbzCore/Player, so the hard-won renderer
orchestration (echo-seek rejection, cursor align, queue materialize, shuffle
deferral, load dedup window) can move UP into qconnect-app and be written once
against the trait instead of re-derived per frontend (slice 6, Phase T 1-2).

- Adds qbz-player as a qconnect-app dep, for PlaybackState (read by the echo
  gate + should_reload; it is not in qbz-models and not re-exported). qbz-player
  does NOT depend on qconnect-app -> no dependency cycle.
- The two protected bit-perfect seams (play_data / play_streaming_dynamic) are
  reached ONLY through the high-level start_track_stream method; BufferWriter +
  reqwest + the detached feeder spawn stay impl-side and never cross the crate
  boundary. play_data / play_streaming_dynamic are never modified.

No impls, no logic moved yet -- pure foundation. qconnect-app 38 green, src-tauri
cargo check green.

cc
2026-06-02 16:43:08 -06:00
vicrodh 6b26ea0e5e fix(qconnect): subscribe transport events before the session-loop spawn (slice 5)
Slice 5 moved the transport subscription INTO the spawned run_session_loop
(self.subscribe_transport_events()), which races the WS handshake:
NativeWsTransport::connect spawns the WS loop and returns WITHOUT awaiting it,
and tokio broadcast has no replay -- so the initial Connected / Subscribed /
SessionEstablished / SESSION_STATE events could be sent to zero receivers and
silently dropped before the spawned task is first polled (a lost
SessionEstablished leaves the UI stuck Connecting; a lost SESSION_STATE skips
the deferred renderer join). The 42 wire tests miss it: they subscribe before
connect against a non-self-driving InMemoryWsTransport.

Restore the prior guarantee -- subscribe synchronously in connect() right after
app.connect() returns and before the spawn / any further await, and pass the
broadcast::Receiver into run_session_loop. Found by adversarial review.

qconnect-app 38 green, src-tauri qconnect 42 green.

cc
2026-06-02 16:32:08 -06:00
vicrodh a07bb36327 feat(qconnect): factor transport loop into QconnectApp::run_session_loop (slice 5)
Move the spawned transport event-loop body out of the Tauri adapter into the
shared QconnectApp::run_session_loop, so both frontends inherit takeover
arbitration / deferred renderer-join / reconnect resync / Lagged re-ask. The
adapter-coupled seams go through a new SessionLoopHost trait:

- update_lifecycle      -- Tauri gates on a live runtime + dedups, emits via sink
- bootstrap_after_reconnect
- deferred_renderer_join -- R2: the CoreBridge track-duration read stays adapter-side
- on_reconnect_exhausted -- R3: Exhausted + last_error + runtime=None teardown +
  the raw qconnect:status_changed emit; returns whether the loop should break
- on_loop_error          -- the raw qconnect:error channel

TauriSessionLoopHost implements it over the existing service handles; connect()
is reduced to: build host -> spawn(app.run_session_loop(host, idle_retry)). The
diagnostic/lifecycle/resync emits still route through self.sink, so the Svelte
wire surface is byte-identical. hex_preview + queue_payload_track_preview + the
re-ask budget const relocate with the loop; the dead Tauri copies are removed.
R3 (QconnectRuntime/JoinHandle ownership, #358 latch, disconnect teardown) stays
adapter-side.

qconnect-app 38 green, src-tauri qconnect 42 green (the byte-identity fence).

cc
2026-06-02 16:23:46 -06:00
vicrodh e8ea401af3 refactor(qconnect): move loop helpers to qconnect-app (slice 5)
Relocate the transport-loop helpers out of the Tauri adapter into QconnectApp
methods + qconnect-app free items, so the shared session loop (slice 5.2) and a
future Slint adapter use one definition:

- QconnectApp::capture_session_state_takeover_input (locks self.sync)
- QconnectApp::clear_pending_if_matches (locks self.state)
- QconnectApp::send_set_active_renderer  -- inline {"renderer_id": i32} payload,
  byte-identical to the prior QconnectSetActiveRendererRequest serialization
- QconnectApp::ask_for_active_renderer_state / QconnectApp::ask_for_queue_state
- SessionStateTakeoverInput struct + queue_payload_track_preview (pure free fn)

Deleted the service.rs free fns (capture_session_state_takeover_input,
send_set_active_renderer_via_app, ask_for_active_renderer_state,
ask_for_queue_state, queue_payload_track_preview, clear_pending_if_matches) and
the SessionStateTakeoverInput struct; rewired every call site (the loop +
deferred_renderer_join + the reconnect/lagged paths) to the methods. The request
structs serialize identically to the inline json!, so the wire is unchanged.

qconnect-app 38 green, src-tauri qconnect 42 green.

cc
2026-06-02 16:05:20 -06:00
vicrodh 776b057b5f refactor(qconnect): delegate session-apply + watchdog to qconnect-app; drop Tauri-side duplicate (slice 2+4)
The Tauri event sink's apply_session_management_event is now a thin delegator:
it resolves this device's identity (resolve_local_identity) and calls
QconnectApp::apply_session_management_event, then runs the post-lock CoreBridge
work the returned SessionApplyOutcome asks for in the exact prior order
(loop mode -> local-playback handoff -> projection -> freeze -> watchdog).
Freeze + watchdog route through app.freeze_active_renderer_projection /
app.arm_renderer_watchdog (same shared sync Mutex, same epoch guard).

Deleted from the Tauri adapter (now solely in qconnect-app): the locked
session-management match, bump_watchdog_generation, the inline 12s watchdog
spawn, freeze_active_renderer_projection_to_unknown, and the duplicate
ensure_session_renderer_state / sync_session_renderer_active_flags. ensure_* is
re-exported from qconnect-app for service.rs; refresh_local_renderer_id wrapper
is replaced by resolve_local_identity (returns the injected LocalIdentity), and
the 3 renderer-id-resolution tests call the pure qconnect-app fn with it.

Byte-identity fence holds: src-tauri qconnect 42 green, qconnect-app 38 green.

cc
2026-06-02 13:45:33 -06:00
vicrodh 77717546fb feat(qconnect): session-apply + watchdog API on QconnectApp (slice 2+4)
Relocate the session-management apply + renderer-liveness logic into
qconnect-app so both the Tauri and the future Slint adapter share it:

- apply_session_management_event(message_type, payload, identity)
  -> SessionApplyOutcome: the locked critical section, byte-identical,
  now locking self.sync. Local identity is injected as data (LocalIdentity)
  so the crate stays frontend-agnostic (refresh_local_renderer_id takes it
  instead of resolving device-info itself). The CoreBridge post-lock work is
  RETURNED via the outcome rather than run inline -- materialization stays
  adapter-side (renderer-engine seam, slice 6). Early returns yield a default
  outcome, exactly as the prior `return;` skipped the post-lock block.
- arm_renderer_watchdog / run_renderer_watchdog: the generation-guarded 12s
  liveness task, modeled on watch_pending_action_timeout (tokio::spawn + sleep
  + re-lock + 4-field epoch revalidation).
- freeze_active_renderer_projection: cached playing_state -> UNKNOWN, then
  emit the freeze event through the sink (dedicated channel + blanket event
  preserved).
- ensure_session_renderer_state + sync_session_renderer_active_flags moved to
  qconnect-app (pure mutators over the relocated accumulator).

Adds 5 tests (SESSION_STATE outcome, watchdog arm/fire-after-12s/epoch-disarm,
ACTIVE_DISCONNECTED freeze) using start_paused + advance. The Tauri sink keeps
its own live copy this commit; slice 2+4.5 rewires it to delegate and deletes
the duplicate. qconnect-app 38 green, src-tauri qconnect 42 green.

cc
2026-06-02 13:34:03 -06:00