Commit Graph
5042 Commits
Author SHA1 Message Date
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
Victor Rodriguez 85518699d2 chore(scripts): add macOS dev build/run script for qbz-slint
slint-dev.sh / slint-dev-fast.sh assume a Linux toolchain (mold linker,
x86 AES-NI features). On macOS clang rejects -fuse-ld=mold and the build
fails at the first link. This adds slint-dev-mac.sh: same nightly parallel
frontend and release-by-default intent, with --fast for a quick debug
build, the AES-NI/SSSE3 features scoped to x86_64 only, and the default
Apple linker (ld-prime). Verified on Apple Silicon: builds, runs, plays audio.
2026-06-03 20:58:50 -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
vicrodh 7b143c2738 feat(qconnect): own remote-sync Mutex on QconnectApp via new(transport, sink, sync) + sync_handle (slice 2+4)
QconnectApp now holds the shared QconnectRemoteSyncState accumulator behind
its own Mutex, disjoint from `state` (the two are never co-locked). The Tauri
adapter still builds the Arc and passes the SAME clone to its event sink /
service loop and into new(), so there is exactly one lock backing the
session/liveness/renderer paths -- the app simply gains a handle to it via
sync_handle(). Pure wiring, byte-identical locking, no logic moved. Prepares
slices 2+4.4/5 to relocate the session-apply + watchdog logic into qconnect-app.

cc
2026-06-02 13:22:41 -06:00
vicrodh a79971e470 refactor(qconnect): relocate QconnectRemoteSyncState struct to qconnect-app (slice 2+4)
Move the cross-submodule remote-sync accumulator into the new
frontend-agnostic qconnect_app::sync_state module. All field types are
now frontend-agnostic (primitives / qconnect-core / qconnect-app), so
the struct moves cleanly with pub fields; the Tauri qconnect module
re-exports it so existing super::QconnectRemoteSyncState references and
the Arc<Mutex<>> creation/threading in service.rs compile unchanged.

It stays ONE struct under ONE lock by design: capture_session_state_takeover_input
reads session topology together with last_renderer_playing_state, and the
materialize path reads last_renderer_* with last_applied_queue_state, all
atomically. Splitting them would tear the takeover decision, so the whole
accumulator relocates as a unit (single-lock locking is byte-identical).

Two test-only re-exports (find_unique_renderer_id, QconnectSessionRendererState)
are gated #[cfg(test)] since their non-test consumers moved to qconnect-app.

qconnect-app + src-tauri qconnect tests green (33 / 42), no new warnings.

cc
2026-06-02 12:58:51 -06:00
vicrodh 979a09e7b2 refactor(qconnect): move session mutators to qconnect-app with injected identity (slice 2+4)
Relocate the pure session mutators (refresh_local_renderer_id,
is_peer/local_renderer_active, find_unique_renderer_id,
normalize_active_renderer_id, renderer_allows_remote_volume) and the
QconnectFileAudioQualitySnapshot type into qconnect-app::session.

refresh_local_renderer_id now takes an injected LocalIdentity (device
uuid + device-info) so qconnect-app stays free of the persistence /
transport crates (ADR-006). A thin Tauri wrapper resolves the identity
from default_qconnect_device_info() and delegates, keeping the three
event_sink call sites and the existing tests unchanged. The eager
device-info build is idempotent and side-effect-free, so renderer-id
resolution is byte-identical.

qconnect-app + src-tauri qconnect tests green (33 / 42).

cc
2026-06-02 12:47:10 -06:00
vicrodh b6ddfc1b5f refactor(qconnect): relocate session topology types to qconnect-app (slice 2+4)
Move QconnectSessionState, QconnectRendererInfo, and
QconnectSessionRendererState from the Tauri adapter into the
frontend-agnostic qconnect-app::session module, re-exporting them from
the Tauri qconnect module so all existing super:: references and the
command surface compile unchanged. Field visibility widened to pub for
cross-crate access; serialized shape is unchanged.

First step of hoisting the QConnect session/liveness layer up so both
the Tauri adapter and a future Slint adapter share one session model.
qconnect crates + src-tauri qconnect tests green.

cc
2026-06-02 12:19:15 -06:00
vicrodh ec4b10fcb6 chore(slint): faster dev builds — mold linker + debug fast script
Add scripts/slint-dev-fast.sh: a DEBUG (non-release) build for quick visual
iteration — opt-level 0 so the giant Slint-generated module skips the heavy
LLVM passes, plus the mold linker, no debuginfo, and the nightly parallel
frontend. It builds into target/debug, separate from the release cache.

slint-dev.sh (release, kept for perf measurement) gains the mold linker and
bumps the frontend threads 8 -> 16 — both only cut compile time, the optimised
binary is unchanged.

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

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

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

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

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

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

cc
2026-06-02 06:48:36 -06:00
vicrodh c763d5b2fe fix(slint): reconcile now-playing card to the live track on seamless change
The poll loop's seamless-transition branch guessed whether a track-id change
while still playing was a gapless hand-off via a peek_upcoming(1) == track_id
heuristic. When that heuristic missed (queue pointer lagging the engine), the
meta was never refreshed: the seek bar kept advancing while the title/art/album
stayed on the previous track (the reported now-playing populate bug).

Replace the heuristic with reconciliation to what is ACTUALLY playing. New
frontend-agnostic primitive QueueManager::sync_current_to_id(id) moves the queue
pointer to the track the engine advanced to (recording history + keeping the
shuffle cursor aligned) and reports whether it moved; QbzCore wraps it and emits
a queue update only on a real move. The poll loop now syncs to the live track_id
and always refreshes the card, doing the record/sidebar/prefetch bookkeeping only
on a genuine advance (a manual play already moved the pointer, so it is idempotent).

cc
2026-06-01 13:29:21 -06:00
vicrodh 63d9f6b496 refactor(qconnect): route liveness/lifecycle/diagnostic emits through event sink (slice 3)
Add five QconnectAppEvent variants (RendererUnreachable, RendererDisconnected,
ResyncComplete, LifecycleChanged, Diagnostic) and a Tauri mapper so the
session/liveness/diagnostic signals flow through the QconnectEventSink trait
instead of raw app_handle.emit calls. Move QconnectLifecycleState into
qconnect-app (pure data); Tauri types.rs re-exports it for the command surface.

No logic moves: the session logic still lives Tauri-side but now calls
sink.on_event(variant) at each rewired emit site. The sink mapper turns each
variant back into the exact same qconnect:* channel + payload, so the Svelte
wire surface is byte-identical. Each dedicated channel still fires exactly once
per occurrence (raw emits are replaced, not duplicated). The blanket
qconnect:event emit now also carries the new variants, which is additive and
ignored by the Svelte listener (pattern-matches known keys only).

Rewired emit sites:
- event_sink.rs freeze helper now takes/emits the freeze variant
  (RendererUnreachable/RendererDisconnected); the mapper emits the dedicated
  channel. State mutation precedes on_event and the variant arms only emit, so
  there is no re-entrancy/recursion into the watchdog/session dispatch.
- service.rs resync-complete -> ResyncComplete; inbound_queue_event/cloud_error/
  max_reconnect -> Diagnostic; update_lifecycle_state_if_running -> LifecycleChanged.

Tests: add qconnect-app unit tests asserting the new variants flow through a
sink and that QconnectLifecycleState serializes snake_case (matching the wire
payload). crates green (qconnect-app 33 passed); src-tauri cargo test qconnect
42 passed.

cc
2026-06-01 09:20:12 -06:00
vicrodh f907d9d984 refactor(qconnect): move device_uuid persistence to qbz-app (slice 1)
Relocate the persistent QConnect device-identity resolution out of the
Tauri adapter into the frontend-agnostic qbz-app crate
(qbz-app/src/qconnect_identity.rs) so both the Tauri and a future Slint
frontend resolve the SAME uuid for the same install.

Moved: resolve_qconnect_device_uuid, device_uuid_from_db,
load/persist_device_uuid, qconnect_settings_db_path.

Byte-identical persistence contract preserved: DB path
(<data_dir>/qbz/qconnect_settings.db), the 'device_uuid' key, and the
QBZ_QCONNECT_DEVICE_UUID env override are unchanged, so existing installs
keep their uuid. The Tauri transport.rs::resolve_qconnect_device_uuid is
now a one-line delegate; the device-NAME persistence shares the same DB
file via the qbz-app path helper.

qbz-app gains a uuid dependency (it already had rusqlite + dirs, so no
SQLite dep was added to qconnect-app per R6). The 2 device_uuid tests
move to qbz-app.

cc
2026-06-01 09:03:13 -06:00
vicrodh f100cf4d75 refactor(qconnect): relocate pure session/liveness fns to shared crates (slice 0)
Move the frontend-agnostic pure functions and enums out of the Tauri
qconnect adapter into the shared crates, with zero behavior change. The
Tauri side keeps thin re-exports so all existing call sites compile
unchanged.

Into qconnect-app (new `session` module):
  - should_arm_renderer_watchdog
  - compute_connection_state + ServerActiveState + ConnectionDecision
  - deferred_join_reason, should_reask_queue_state
  - RendererStatus + from_wire
  - quality_from_max_audio_quality
  - QCONNECT_RENDERER_LOST_TIMEOUT_MS + JoinSession reason consts

Into qconnect-core (next to evaluate_remote_queue_admission):
  - validate_track_origins_for_admission (pure over TrackOrigin); the
    Tauri command wrapper stays thin and only maps wire origins to core
    origins.

The 6 pure-fn unit tests move into the destination crates' test
modules. R5 honored: compute_connection_state matrix cells are relocated
verbatim, not changed.

cc
2026-06-01 08:55:04 -06:00
vicrodh f3d37dac42 merge: Qobuz Connect hardening (jun-2026)
Land the qobuz-connect-hardening-jun-2026 initiative (31 commits): renderer-liveness
watchdog + ACTIVE_DISCONNECTED + persistent device_uuid, track-source admission gate
(2 critical on-connect/loadQconnectQueue bypass fixes + shared is_castable_to_qconnect
predicate + Rust per-track backstop), set_position_if_remote seek, PlaybackError
auto-skip, queue_hash surface+inert scaffold, autoplay-growth resync, transport
resilience (require_jwt hard error, keepalive pong-timeout, idle-retry recovery; #358
latch preserved), session resilience (RECONNECTION reason, computeConnectionState
takeover arbitration, reconnect renderer-state resync, replay advisory), and renderer
features (volume capability gate, SetMaxAudioQuality/SetShuffleMode/SetActive honoring,
DeviceAudioQualityChanged(27) report). Also unblocks the src-tauri build by adding the
missing AudioBackendType::Jack match arm. Known gaps documented: remote queue reorder
left local-only, queue_hash algorithm unknown (scaffold inert).

cc
2026-06-01 06:20:54 -06:00
vicrodh 098ccd9e06 fix(qconnect): wire remote queue clear/remove routing at call sites
The P1-4 remote-queue routers (clear/remove/reorder) existed and were
unit-tested but their call sites in +page.svelte were never wired, so
reorder/remove/clear kept diverging from the renderer when a Qobuz
Connect peer owns playback.

- handleClearQueue: route to clearQconnectQueueIfRemote() first when a
  peer renderer is active; return before the local clearQueue() to avoid
  double-mutation.
- handleRemoveFromQueue: map the local upcoming index to the renderer's
  stable queue_item_id via the QConnect queue snapshot (slice past the
  current track, index into the upcoming portion, corroborate by
  track_id), then route to removeQconnectQueueItemsIfRemote(). Falls
  through to the local invoke if the id can't be resolved unambiguously.
- handleQueueReorder: left local-only by design (DONE_WITH_CONCERNS).
  The remote queue_reorder_tracks command needs the full target order of
  queue_item_ids, which can't be reconstructed reliably from a local
  upcoming from/to index pair (no per-row queue_item_id, ambiguous
  duplicate track_ids, renderer shuffle/autoplay ordering). Documented
  inline; sending wrong ids would silently corrupt the renderer queue.

Gate is qconnectPeerRendererActive (matching seek/volume/play-track
handlers), not bare transport-connected, so local playback under a
connected-but-not-peer session keeps mutating the local store.

cc
2026-06-01 05:14:53 -06:00
vicrodh 145859584f feat(qconnect): route queue reorder/remove/clear to the remote queue
Extend the QconnectQueueCommandType union with queue_reorder_tracks,
queue_remove_tracks and clear_queue, and add the exported
reorderQconnectQueueIfRemote / removeQconnectQueueItemsIfRemote /
clearQconnectQueueIfRemote routing functions. Each returns true when the remote
queue handled (or reported) the mutation and false when the caller should fall
back to the local store. Add the three qconnect.remoteQueue* toast keys to all
five locales and a covering test for the connected/disconnected paths.

cc
2026-06-01 04:48:54 -06:00