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
This commit is contained in:
vicrodh committed 2026-06-02 16:05:20 -06:00
1 parent 776b057b5f
commit e8ea401af3
4 files changed
+183 -187

No files matched your search

+161 -3
View File
@@ -19,9 +19,10 @@ use tokio::sync::Mutex;
use uuid::Uuid;
use crate::session::{
is_peer_renderer_active, normalize_active_renderer_id, refresh_local_renderer_id,
should_arm_renderer_watchdog, LocalIdentity, QconnectRendererInfo, RendererStatus,
PLAYING_STATE_PLAYING, PLAYING_STATE_UNKNOWN, QCONNECT_RENDERER_LOST_TIMEOUT_MS,
is_local_renderer_active, is_peer_renderer_active, normalize_active_renderer_id,
refresh_local_renderer_id, should_arm_renderer_watchdog, LocalIdentity, QconnectRendererInfo,
RendererStatus, ServerActiveState, PLAYING_STATE_PLAYING, PLAYING_STATE_UNKNOWN,
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
};
use crate::{
ensure_session_renderer_state, sync_session_renderer_active_flags, QconnectAppError,
@@ -1445,6 +1446,163 @@ where
}
}
/// Prior-state snapshot captured from the sync accumulator BEFORE a SESSION_STATE
/// frame is applied, plus the server's classified active state derived from the
/// incoming payload. Drives P1-3 takeover arbitration after the apply. Relocated
/// from the Tauri adapter (slice 5) so the shared session loop owns it.
#[derive(Debug, Clone, Copy)]
pub struct SessionStateTakeoverInput {
pub was_active: bool,
pub was_playing: bool,
pub server: ServerActiveState,
}
/// Preview the first 8 `track_id`s under `payload[key]` for diagnostic emits.
/// Pure; relocated from the Tauri adapter (slice 5).
pub fn queue_payload_track_preview(payload: &Value, key: &str) -> Vec<i64> {
payload
.get(key)
.and_then(Value::as_array)
.map(|tracks| {
tracks
.iter()
.filter_map(|track| track.get("track_id").and_then(Value::as_i64))
.take(8)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
impl<TTransport, TSink> QconnectApp<TTransport, TSink>
where
TTransport: WsTransport + 'static,
TSink: QconnectEventSink + 'static,
{
/// Capture `was_active`/`was_playing` from the current (pre-apply) sync state
/// and classify the server's active-renderer state from the incoming
/// SESSION_STATE payload, relative to our local renderer id. Pure-ish read;
/// takes no decision. Relocated from the Tauri adapter (slice 5); locks
/// `self.sync` (the same accumulator the apply path mutates).
pub async fn capture_session_state_takeover_input(
&self,
payload: &Value,
) -> SessionStateTakeoverInput {
let st = self.sync.lock().await;
let local_id = st.session.local_renderer_id;
let was_active = is_local_renderer_active(&st.session);
let was_playing = st.last_renderer_playing_state == Some(PLAYING_STATE_PLAYING);
let incoming_active = payload
.get("active_renderer_id")
.and_then(Value::as_i64)
.and_then(|value| i32::try_from(value).ok());
let server = match incoming_active {
None => ServerActiveState::None,
Some(id) if Some(id) == local_id => ServerActiveState::Me,
Some(_) => {
// Another renderer owns the slot. Classify playing vs paused from
// the last observed peer playing_state; default OtherPaused when unknown.
if st.last_renderer_playing_state == Some(PLAYING_STATE_PLAYING) {
ServerActiveState::OtherPlaying
} else {
ServerActiveState::OtherPaused
}
}
};
SessionStateTakeoverInput {
was_active,
was_playing,
server,
}
}
/// Clear the pending action slot iff it still matches `action_uuid`. Used for
/// replies that are not reducer-correlated (set-active, ask-for-state). Locks
/// `self.state` (NEVER co-locked with `self.sync`). Relocated from the Tauri
/// adapter (slice 5).
pub async fn clear_pending_if_matches(&self, action_uuid: &str) {
let mut state = self.state.lock().await;
let pending_matches = state
.pending
.current()
.map(|pending| pending.uuid == action_uuid)
.unwrap_or(false);
if pending_matches {
state.pending.clear();
}
}
/// Guarded `CtrlSrvrSetActiveRenderer` sender. No-ops when the target equals
/// the current active renderer or the id is invalid. Never resets reconnect
/// counters (#358 latch untouched). Relocated from the Tauri adapter's
/// `send_set_active_renderer_via_app` (slice 5); the request payload
/// `{ "renderer_id": <i32> }` is byte-identical to the prior
/// `QconnectSetActiveRendererRequest` serialization.
pub async fn send_set_active_renderer(&self, target_renderer_id: i32) -> Result<bool, String> {
if target_renderer_id < 0 {
return Ok(false);
}
{
let st = self.sync.lock().await;
if st.session.active_renderer_id == Some(target_renderer_id) {
return Ok(false);
}
}
let payload = serde_json::json!({ "renderer_id": target_renderer_id });
let command = self
.build_queue_command(QueueCommandType::CtrlSrvrSetActiveRenderer, payload)
.await;
let uuid = self
.send_queue_command(command)
.await
.map_err(|err| format!("send set_active_renderer failed: {err}"))?;
self.clear_pending_if_matches(&uuid).await;
Ok(true)
}
/// Ask the currently-active renderer for its full state (P1-8). No-op when no
/// active renderer is known. The reply is not reducer-correlated, so its
/// pending slot is cleared immediately. Relocated from the Tauri adapter
/// (slice 5); payload byte-identical to the prior
/// `QconnectAskForRendererStateRequest` serialization.
pub async fn ask_for_active_renderer_state(&self) -> Result<bool, String> {
let active_id = {
let st = self.sync.lock().await;
st.session.active_renderer_id
};
let Some(active_id) = active_id else {
return Ok(false);
};
let payload = serde_json::json!({ "renderer_id": active_id });
let cmd = self
.build_queue_command(QueueCommandType::CtrlSrvrAskForRendererState, payload)
.await;
let uuid = self
.send_queue_command(cmd)
.await
.map_err(|err| format!("send ask_for_renderer_state failed: {err}"))?;
self.clear_pending_if_matches(&uuid).await;
Ok(true)
}
/// Ask the server for the current queue state (P1-8 Lagged-recovery / generic
/// re-sync). The reply is not reducer-correlated, so its pending slot is
/// cleared. Relocated from the Tauri adapter (slice 5).
pub async fn ask_for_queue_state(&self) -> Result<(), String> {
let cmd = self
.build_queue_command(
QueueCommandType::CtrlSrvrAskForQueueState,
serde_json::json!({}),
)
.await;
let uuid = self
.send_queue_command(cmd)
.await
.map_err(|err| format!("send ask_for_queue_state failed: {err}"))?;
self.clear_pending_if_matches(&uuid).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+3 -1
View File
@@ -11,7 +11,9 @@ mod state;
pub mod startup;
mod sync_state;
pub use app::{QconnectApp, SessionApplyOutcome};
pub use app::{
queue_payload_track_preview, QconnectApp, SessionApplyOutcome, SessionStateTakeoverInput,
};
pub use startup::{compute_effective_startup, QconnectStartupMode};
pub use error::QconnectAppError;
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};