Commit Graph
67 Commits
Author SHA1 Message Date
vicrodh d82602e2c7 fix(qconnect): preserve playback state and queue occurrence identity
Keep queue edits from resetting the playback clock, preserve Play next and Play later batches, and report observed renderer state and owned output telemetry. Guard duplicate-track gapless transitions by queue occurrence.

cx
2026-09-09 13:08:39 -06:00
vicrodh 9b3b52cf9c fix(qconnect): preserve controller state across renderer handoffs
Keep queue reads from starving controls without weakening real queue-write serialization. Restore official proto3 zero defaults and next/previous loop, shuffle and autoplay semantics.

Project controls from the active peer state and target exact upcoming queue occurrences without optimistic cursor changes or local playback fallback. Cover takeover, seek, mute, wire decoding and manual skip regressions in the CI gate.

cx
2026-09-05 18:37:07 -06:00
vicrodh f6e1c4e023 fix(qconnect): harden queue ownership handoffs
Preserve local playback when it is authoritative, make renderer conflicts explicit and persist the selected policy. Filter unsupported queue rows, restore portable Qobuz identities after disconnect, and fence takeover commands to avoid stale queue and playback loops.

cx
2026-09-01 17:23:14 -06:00
vicrodh 2e55f01e26 refactor(qconnect): share delegation lifecycle
Move authority fences, preflight, rejoin watchdogs, LAN projection, and physical runtime teardown into qconnect-app for both Qt and qbzd.

cx
2026-08-31 03:26:01 -06:00
vicrodh de289bbf46 fix(qconnect): align buffering state reports
Preserve playing intent while buffering and emit ready only at initial decode or stall recovery across both adapters.

cx
2026-08-31 03:25:11 -06:00
vicrodh 94904f70c6 fix(qconnect): follow official renderer authority
Reproduce the server-authored shuffle order, defer renderer detach to topology, and project remote volume changes into the Qt UI.

cx
2026-08-31 03:23:49 -06:00
vicrodh b4404f45ba fix(qconnect): enforce shared lifecycle authority
cx
2026-08-30 22:22:08 -06:00
vicrodh 74a41150e9 feat(qconnect): add transactional LAN delegation
cx
2026-08-30 15:33:23 -06:00
vicrodh 52c22006ae feat(qconnect): report real buffer state
cx
2026-08-30 13:34:25 -06:00
vicrodh 9493e96e05 fix(qconnect): align auth and playback wire
cx
2026-08-30 13:08:58 -06:00
vicrodh 957ce924b3 chore(deps): prost 0.14
cc
2026-08-29 08:24:28 -06:00
vicrodh ef665dcc5c feat(models): QueueTrack carries isrc/recording_mbid; Track models audio_info
QueueTrack gains two additive, serde-default identity keys (the ISRC and
the MusicBrainz recording id) that the listen log stamps; every
constructor in the workspace sets them (Qobuz-backed ones from
Track.isrc, local ones from the scanned tags, the rest None). They are
JOIN keys, not the row identity: numeric ids collide across sources, so
(source, native item id) stays the identity.

Track.audio_info (replaygain_track_gain / peak on DiscoverAudioInfo) is
modelled from the captured search envelopes; no reader yet and no
popularity field (0 in every sample).

cc
2026-08-28 20:30:42 -06:00
vicrodh e109e88ec2 merge: integrate qbzd event hooks from PR #700
cx
2026-08-27 19:26:13 -06:00
vicrodh 5e4d7ca033 fix(queue): preserve metadata and polish extended view
cx
2026-08-22 20:58:46 -06:00
vicrodh 48c6f6854d fix(qt): harden remote playback transitions
cx
2026-08-22 15:42:06 -06:00
Filippo Vicentini 7f9397f916 fix(qconnect): cold-load the current track on a state-only resume
A state-only SetState resume (playing_state=play, current_track=null)
can reach a renderer whose queue cursor is set but whose engine holds
no loaded audio — the session store restores the queue paused and
unloaded after a restart. The play arm issued a bare engine.resume(),
which dies in the audio thread ("cannot resume - no audio data
available") while the renderer keeps reporting paused 0:00, so a
mid-track handoff from a peer renderer wedges the controlling app
forever.

When the engine has no loaded audio and the cloud state names a
current track, force-stream that track at the reported position
(force_remote_track_stream — the plain track-id guard would skip,
since the restored cursor already reports the track). The
has_loaded_audio gate keeps cloud echoes and live playback on the
plain resume path, and the SetActive takeback fix already established
this exact pattern.

Repro: cast a queue to qbzd, restart the daemon, play on another
renderer, then hand off mid-track by selecting the qbzd renderer in
the app: it sticks at paused 0:00 until a CLI play primes the engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9032e6e027c29b386042fce1b1e95388b20e4f01)
2026-08-20 10:40:59 +02:00
vicrodh 371be04be8 feat(models): Track.streamable becomes Option<bool>, and absence stops meaning dead
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE,
so a catalog Track from any endpoint that omits `streamable` came back marked
unavailable. The models' own tests document this as a hazard rather than a bug
(the "streamable split": PurchaseTrack defaults true, catalog Track defaults
false, and the comment warns that a wrong default "inverts that whole screen's
behaviour"). The inferred OpenAPI disagrees with the code and documents
`default: true`.

That was survivable only because nothing read the field. It stops being
survivable the moment a frontend greys rows out and keeps them out of the
queue: one terse endpoint would blank an entire view, silently.

`None` now means the endpoint made no claim, and only `Some(false)` marks a
track pulled. `is_streamable()` is the ONE place absence is interpreted, and it
resolves to available: refusing to play a track because a response was terse is
strictly worse than playing one that turns out to be dead, which the reactive
skip path already handles. Album.streamable was already Option<bool> with a doc
comment naming the same causes, so this makes Track consistent rather than
novel.

Every consumer in the workspace moves to the helper: both matchers, the ISRC
validator, the daemon driver, the purchases service (which maps catalog Track
into PurchaseTrack and therefore feeds the very screen the split protects), the
radio pool, the mixtape resolver and the QConnect renderer. The replaced tests
pin both directions of the new contract.

Also here, because it lives in the same file as the matcher's streamable skip:
`rank_candidates`, an additive public entry point over the existing weighted
matcher (ISRC short-circuit, title/artist/album weights, duration bonus,
stop-word normalisation, quality tie-break). `match_tracks` is untouched.

cc
2026-08-17 14:40:57 -06:00
vicrodh b3d12e6b2c fix(playback): give the now-playing "source" button a per-track context
The "playing from" button read a single global context set once at play time;
refresh_now_playing_meta never republished it, so it went stale across track
changes (previous track's album) and single-track plays fell back to the
current track's album instead of the playlist it came from. Add per-track
context_kind/context_id to QueueTrack (serde-default None, back-compat), stamp
the real container at every play path (album/artist/playlist/label), and
republish the context from the current track on EVERY track change (gapless
included) — never cached. open-context routes all stamped kinds.

cc
2026-07-13 13:11:43 -06:00
vicrodh 7a31988767 feat(player): show album release version in now-playing + MPRIS
The now-playing bar, MPRIS metadata, and the desktop notification showed
the clean album name; for a re-edition (e.g. Octavarium (2009 Remaster))
the variant was lost. Carry the album-level version on QueueTrack
(populated on the album-play path) and append it to the displayed album.
Last.fm scrobbling keeps the CLEAN album name so editions don't fragment
stats. New album_version field defaults to None on every other queue source.

cc
2026-06-30 14:07: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 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 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 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 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 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 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 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 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 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 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 fea9dbe1d4 feat(qconnect-app): emit PlaybackError app event from renderer failure
Decodes PlaybackErrorMessage off the InboundPayloadBytes batch and emits a
PlaybackError app event; the Tauri event sink forwards it to qconnect:event
via its existing catch-all emit.

cc
2026-06-01 03:23:03 -06:00
vicrodh 92695c222a feat(qconnect): queue_hash divergence scaffold + AskForQueueState trigger
Comparison seam joins the existing resync path. Local hash algorithm is a
documented BLOCKING open question; stub returns None so detection stays inert
(no spurious resyncs) until the Qobuz algorithm is verified.

cc
2026-06-01 03:17:09 -06:00
vicrodh 3ac68e788a feat(qconnect): surface server queue_hash onto queue state
Decoder now reads field #100 (previously decoded-then-dropped) on QueueState
and carries it through to QConnectQueueState.last_server_queue_hash via the
QueueStateReplaced reducer path (full-state replace already clones it).

cc
2026-06-01 03:16:16 -06:00
vicrodh 452f961977 fix(qconnect): force AskForQueueState resync on QueueTracksAddedFromAutoplay
105 carries only bare queue_item_ids (no track_id/context), unreconstructable
by the reducer; resync pulls the authoritative QueueState so autoplay-grown
tracks become visible. Matches the ShuffleModeSet/Reordered/Removed pattern.

cc
2026-06-01 03:09:17 -06:00
vicrodh 6afbc94171 fix(qconnect): honor peer-controller seeks when qbz is the active renderer
Fixes #387: seeks issued from the official Qobuz mobile app while qbz
acts as the active renderer were silently dropped — audio kept playing
from the original position while the controller's progress bar locked.

Two independent gates were swallowing the command between the
WebSocket and the audio thread; either alone is enough to break the
flow, so both move together.

1. Upstream filter (crates/qconnect-app/src/app.rs)

   The is_echo guard treated every SET_STATE with playing_state=None
   AND current_track=None as a pure server echo and skipped emitting
   RendererCommandApplied. iOS sends a real seek as exactly that
   shape (only current_position_ms set, the rest unchanged), so the
   command never reached the bridge handler.

   Add current_position_ms.is_none() to the guard. Pure echoes still
   match (server echoes carry only next_track); position-only seeks
   pass through.

2. Downstream gate (src-tauri/src/qconnect/corebridge.rs)

   The seek branch was conditioned on peer_renderer_active — i.e. it
   only acted when qbz was the controller observing a peer renderer,
   never when qbz was the renderer itself. Removed; the existing
   is_echo_reset and abs_diff > 2 checks already filter the redundant-
   seek case the gate was originally added to defend against (147bcbd7).
   Drop the now-unused is_peer_renderer_active import.

Verified manually: seeks from iPhone (renderer_id=3) targeting qbz
(renderer_id=2) at ~1/4 and ~5/6 of a 150s track now translate to
local audio jumps to the correct positions.

cc
2026-05-06 14:21:27 -06:00
vicrodh 1b01c95898 feat(qconnect-app): add QconnectStartupMode + compute_effective_startup
Pure enum + decision function for QConnect auto-connect-on-startup.
Persistence and CLI parsing stay in the Tauri adapter; this crate
remains UI-agnostic.

cc
2026-05-01 12:12:09 -06:00
vicrodh d74332514d fix(qconnect-transport-ws): decode MSG_TYPE_ERROR frame and surface code+descr
The MSG_TYPE_ERROR (cloud_type=9) arm previously logged only the payload
byte count, so when Qobuz cloud rejected a session (zombie session,
conflicting device, expired JWT, ...) we had no signal beyond an opaque
"bytes=13" TransportError. That hid the actual cause of the 2s flicker
loop seen after the app sat minimized for 1-2 hours (issue #358).

The qws.proto ErrorMessage (messageTypeId=9) carries:
  uint32 msg_id   = 1
  uint64 msg_date = 2
  uint32 code     = 3
  string descr    = 4

Decode it via a local QwsErrorMessage prost struct, log a warn line with
the decoded code+descr, and emit a new TransportEvent::CloudError so
qconnect-app and qconnect_service can react. qconnect_service forwards
it as a "qconnect:cloud_error" diagnostic (level=warning) and logs it.
qconnect-app treats it as a no-op forwarder for now, matching the
existing pattern for other transport-only events. Decode failures fall
back to the previous TransportError path under a distinct stage name so
they are still visible.

Adds a tokio test that builds a real MSG_TYPE_ERROR frame and asserts
the transport emits CloudError (not the legacy TransportError).

Refs #358

cc
2026-04-25 20:44:46 -06:00
vicrodh ecc27bbcaa fix(qconnect-transport-ws): reset backoff only on SESSION_STATE, cap reconnect attempts
The native WS transport reset its reconnect counters as soon as the WS
upgrade succeeded. Qobuz cloud accepts the WS connection well before the
JOIN_SESSION exchange and rejects unhealthy sessions with MSG_TYPE_ERROR
right after, so the counter reset every cycle and the loop hammered the
endpoint at base backoff (~2s) forever (issue #358).

Move the counter reset to the moment the cloud emits a
MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE event (the actual proof of a healthy
session-level join), and add a configurable maximum-attempts cap that
makes the transport stop and emit MaxReconnectAttemptsExceeded instead
of looping indefinitely.

- Add SessionEstablished and MaxReconnectAttemptsExceeded transport events.
- Add reconnect_max_attempts to WsTransportConfig (default Some(10)).
- handle_incoming_binary now returns whether SESSION_STATE was decoded.
- Reconnect helper returns a ReconnectOutcome enum (Continue/Shutdown/
  Exhausted) instead of a bare bool.
- qconnect_service.rs logs the new events and emits a diagnostic when
  the cap is exceeded so the higher layer can react (#358-fix-3 will
  surface it in the UI).
- Unit tests cover the cap behavior and the unbounded fallback.

Refs #358

cc
2026-04-25 20:01:42 -06:00
vicrodh 3b3dbc7ce1 fix(qconnect): trigger resync on reorder and remove server events
SrvrCtrlQueueTracksReordered and SrvrCtrlQueueTracksRemoved were not
triggering AskForQueueState, so the local queue never reflected
reorder/remove actions performed via QConnect. Same pattern as the
existing shuffle resync.

cc
2026-03-14 21:34:03 -06:00