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
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
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
- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
The server echoes every state report as a SET_STATE with only next_track
(playing_state=null, current_track=null). These echoes were triggering
align_corebridge_queue_cursor which uses the accumulated renderer state
current_track (from a different context, e.g. iOS). When the track
wasn't found in the local queue, it replaced the entire queue with a
single track, destroying the user's loaded queue.
Now echo SET_STATE commands are detected early and only update the
internal renderer state (for next_track tracking). CoreBridge actions
(align cursor, load track, play/pause) and state reports are skipped
entirely for these echoes.
The server echoes every RNDR_SRVR_STATE_UPDATED as a SET_STATE command
with only next_track updated (playing_state=null, current_track=null).
send_renderer_reports was replying to these echo SET_STATE commands
with another state report, creating an infinite loop that generated
47k log lines (8.2MB) in seconds and completely blocked playback.
Fix: only send state reports for substantive SET_STATE commands that
carry playing_state or current_track changes. Echo commands (next_track
only) are silently consumed.
Also demote diagnostic logging from info to debug to reduce terminal
noise during normal operation.
The server rejects renderer state reports with "Current track not found
in queue nor autoplay" after QBZ-initiated queue loads, because it assigns
non-standard queue_item_ids (first track = track_id, rest = 1..N).
Omit current_queue_item_id and next_queue_item_id from all outbound
renderer reports. The server already knows the current track from
SET_STATE commands and adds the correct queue_item_id when forwarding
to controllers.
Also add info-level diagnostic logging to both report paths (periodic
frontend reports and command-response reports) and the protobuf encoder
for verifying wire content.
Root cause: server pause/resume commands send None for position and
tracks, which the renderer state blindly overwrote. Then renderer
reports sent null position to the server, making controllers show the
track as finished.
Three-part fix:
1. renderer.rs: only overwrite position/tracks when server provides
a non-None value (retain last known values through pause/resume)
2. app.rs + qconnect_service.rs: update renderer state position from
frontend periodic reports so the internal state always has the
real playback position
3. +page.svelte: increase position report frequency from 5s to 2s
for smoother seekbar updates on controllers
Add missing SRVR_CTRL session management message types (81-87, 97-101)
that were completely absent from the protobuf definitions. Without these,
prost silently discarded all session topology messages from the server.
- Add 12 new proto structs for session/renderer/volume/quality events
- Add decoder branches and map functions for all new message types
- Add SessionManagementEvent variant to bypass queue reducer
- Add v2_qconnect_report_playback_state and v2_qconnect_report_volume
- Add comprehensive logging for inbound message diagnostics