fix(qconnect): track active remote renderer state for skip and seekbar
cx
This commit is contained in:
4 files changed
+462
-62
No files matched your search
@@ -28,6 +28,7 @@ qbz-cache = { path = "../crates/qbz-cache" }
|
|||||||
qbz-library = { path = "../crates/qbz-library" }
|
qbz-library = { path = "../crates/qbz-library" }
|
||||||
qbz-cast = { path = "../crates/qbz-cast" }
|
qbz-cast = { path = "../crates/qbz-cast" }
|
||||||
qconnect-app = { path = "../crates/qconnect-app" }
|
qconnect-app = { path = "../crates/qconnect-app" }
|
||||||
|
qconnect-core = { path = "../crates/qconnect-core" }
|
||||||
qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
|
qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
|
||||||
|
|
||||||
# Tauri
|
# Tauri
|
||||||
@@ -157,4 +158,3 @@ tauri-plugin-dialog = { version = "2.6.0" }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ use std::{
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use qbz_models::{Quality, QueueTrack, Track};
|
use qbz_models::{Quality, QueueTrack, Track};
|
||||||
|
use qconnect_core::QueueItem;
|
||||||
use qconnect_app::{
|
use qconnect_app::{
|
||||||
evaluate_remote_queue_admission, resolve_handoff_intent, HandoffIntent, QConnectQueueState,
|
evaluate_remote_queue_admission, resolve_handoff_intent, HandoffIntent, QConnectQueueState,
|
||||||
QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink, QueueCommandType,
|
QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectEventSink, QueueCommandType,
|
||||||
@@ -315,6 +316,21 @@ struct QconnectRemoteSyncState {
|
|||||||
last_applied_queue_version: Option<(u64, u64)>,
|
last_applied_queue_version: Option<(u64, u64)>,
|
||||||
/// Session topology — stored from session management events (types 81-87).
|
/// Session topology — stored from session management events (types 81-87).
|
||||||
session: QconnectSessionState,
|
session: QconnectSessionState,
|
||||||
|
session_renderer_states: HashMap<i32, QconnectSessionRendererState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct QconnectSessionRendererState {
|
||||||
|
active: Option<bool>,
|
||||||
|
playing_state: Option<i32>,
|
||||||
|
current_position_ms: Option<u64>,
|
||||||
|
current_queue_item_id: Option<u64>,
|
||||||
|
volume: Option<i32>,
|
||||||
|
muted: Option<bool>,
|
||||||
|
max_audio_quality: Option<i32>,
|
||||||
|
loop_mode: Option<i32>,
|
||||||
|
shuffle_mode: Option<bool>,
|
||||||
|
updated_at_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -357,6 +373,13 @@ fn emit_qconnect_diagnostic(app_handle: &AppHandle, channel: &str, level: &str,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn qconnect_now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct QconnectSessionState {
|
pub struct QconnectSessionState {
|
||||||
pub session_uuid: Option<String>,
|
pub session_uuid: Option<String>,
|
||||||
@@ -434,6 +457,45 @@ fn refresh_local_renderer_id(session: &mut QconnectSessionState) {
|
|||||||
session.local_renderer_id = None;
|
session.local_renderer_id = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_active_renderer_id(value: Option<i64>) -> Option<i32> {
|
||||||
|
value
|
||||||
|
.filter(|renderer_id| *renderer_id >= 0)
|
||||||
|
.and_then(|renderer_id| i32::try_from(renderer_id).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_peer_renderer_active(session: &QconnectSessionState) -> bool {
|
||||||
|
match (session.active_renderer_id, session.local_renderer_id) {
|
||||||
|
(Some(active_renderer_id), Some(local_renderer_id)) => active_renderer_id != local_renderer_id,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_session_renderer_active_flags(state: &mut QconnectRemoteSyncState) {
|
||||||
|
for (renderer_id, renderer_state) in &mut state.session_renderer_states {
|
||||||
|
renderer_state.active = state
|
||||||
|
.session
|
||||||
|
.active_renderer_id
|
||||||
|
.map(|active_renderer_id| active_renderer_id == *renderer_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_session_renderer_state(
|
||||||
|
state: &mut QconnectRemoteSyncState,
|
||||||
|
renderer_id: i32,
|
||||||
|
) -> &mut QconnectSessionRendererState {
|
||||||
|
let active = state
|
||||||
|
.session
|
||||||
|
.active_renderer_id
|
||||||
|
.map(|active_renderer_id| active_renderer_id == renderer_id);
|
||||||
|
state
|
||||||
|
.session_renderer_states
|
||||||
|
.entry(renderer_id)
|
||||||
|
.or_insert_with(|| QconnectSessionRendererState {
|
||||||
|
active,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn find_unique_renderer_id(
|
fn find_unique_renderer_id(
|
||||||
session: &QconnectSessionState,
|
session: &QconnectSessionState,
|
||||||
predicate: impl Fn(&QconnectRendererInfo) -> bool,
|
predicate: impl Fn(&QconnectRendererInfo) -> bool,
|
||||||
@@ -452,6 +514,49 @@ fn find_unique_renderer_id(
|
|||||||
Some(first)
|
Some(first)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn queue_item_snapshot_for_cursor(
|
||||||
|
queue: &QConnectQueueState,
|
||||||
|
cursor: QconnectOrderedQueueCursor,
|
||||||
|
) -> Option<QueueItem> {
|
||||||
|
match cursor {
|
||||||
|
QconnectOrderedQueueCursor::Queue(index) => queue.queue_items.get(index).cloned().map(|mut item| {
|
||||||
|
item.queue_item_id = normalize_current_queue_item_id_from_queue_state(queue, index);
|
||||||
|
item
|
||||||
|
}),
|
||||||
|
QconnectOrderedQueueCursor::Autoplay(index) => queue.autoplay_items.get(index).cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_session_renderer_snapshot(
|
||||||
|
queue: &QConnectQueueState,
|
||||||
|
renderer_state: Option<&QconnectSessionRendererState>,
|
||||||
|
) -> QConnectRendererState {
|
||||||
|
let renderer_state = renderer_state.cloned().unwrap_or_default();
|
||||||
|
let cursors = ordered_queue_cursors(queue);
|
||||||
|
let current_index =
|
||||||
|
find_cursor_index_by_queue_item_id(&cursors, queue, renderer_state.current_queue_item_id);
|
||||||
|
let current_track = current_index
|
||||||
|
.and_then(|index| queue_item_snapshot_for_cursor(queue, cursors[index]));
|
||||||
|
let next_track = current_index
|
||||||
|
.and_then(|index| cursors.get(index + 1).copied())
|
||||||
|
.and_then(|cursor| queue_item_snapshot_for_cursor(queue, cursor));
|
||||||
|
|
||||||
|
QConnectRendererState {
|
||||||
|
active: renderer_state.active,
|
||||||
|
playing_state: renderer_state.playing_state,
|
||||||
|
current_position_ms: renderer_state.current_position_ms,
|
||||||
|
current_track,
|
||||||
|
next_track,
|
||||||
|
volume: renderer_state.volume,
|
||||||
|
volume_delta: None,
|
||||||
|
muted: renderer_state.muted,
|
||||||
|
max_audio_quality: renderer_state.max_audio_quality,
|
||||||
|
loop_mode: renderer_state.loop_mode,
|
||||||
|
shuffle_mode: renderer_state.shuffle_mode,
|
||||||
|
updated_at_ms: renderer_state.updated_at_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl QconnectEventSink for TauriQconnectEventSink {
|
impl QconnectEventSink for TauriQconnectEventSink {
|
||||||
async fn on_event(&self, event: QconnectAppEvent) {
|
async fn on_event(&self, event: QconnectAppEvent) {
|
||||||
@@ -535,9 +640,9 @@ impl TauriQconnectEventSink {
|
|||||||
if let Some(uuid) = payload.get("session_uuid").and_then(Value::as_str) {
|
if let Some(uuid) = payload.get("session_uuid").and_then(Value::as_str) {
|
||||||
state.session.session_uuid = Some(uuid.to_string());
|
state.session.session_uuid = Some(uuid.to_string());
|
||||||
}
|
}
|
||||||
if let Some(id) = payload.get("active_renderer_id").and_then(Value::as_i64) {
|
state.session.active_renderer_id =
|
||||||
state.session.active_renderer_id = Some(id as i32);
|
normalize_active_renderer_id(payload.get("active_renderer_id").and_then(Value::as_i64));
|
||||||
}
|
sync_session_renderer_active_flags(&mut state);
|
||||||
}
|
}
|
||||||
"MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER" => {
|
"MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER" => {
|
||||||
if let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) {
|
if let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) {
|
||||||
@@ -575,6 +680,8 @@ impl TauriQconnectEventSink {
|
|||||||
});
|
});
|
||||||
refresh_local_renderer_id(&mut state.session);
|
refresh_local_renderer_id(&mut state.session);
|
||||||
}
|
}
|
||||||
|
let _ = ensure_session_renderer_state(&mut state, renderer_id);
|
||||||
|
sync_session_renderer_active_flags(&mut state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER" => {
|
"MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER" => {
|
||||||
@@ -619,6 +726,8 @@ impl TauriQconnectEventSink {
|
|||||||
}
|
}
|
||||||
refresh_local_renderer_id(&mut state.session);
|
refresh_local_renderer_id(&mut state.session);
|
||||||
}
|
}
|
||||||
|
let _ = ensure_session_renderer_state(&mut state, renderer_id);
|
||||||
|
sync_session_renderer_active_flags(&mut state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER" => {
|
"MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER" => {
|
||||||
@@ -628,16 +737,107 @@ impl TauriQconnectEventSink {
|
|||||||
.session
|
.session
|
||||||
.renderers
|
.renderers
|
||||||
.retain(|r| r.renderer_id != renderer_id);
|
.retain(|r| r.renderer_id != renderer_id);
|
||||||
|
state.session_renderer_states.remove(&renderer_id);
|
||||||
refresh_local_renderer_id(&mut state.session);
|
refresh_local_renderer_id(&mut state.session);
|
||||||
|
sync_session_renderer_active_flags(&mut state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED" => {
|
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED" => {
|
||||||
if let Some(id) = payload
|
state.session.active_renderer_id =
|
||||||
.get("active_renderer_id")
|
normalize_active_renderer_id(payload.get("active_renderer_id").and_then(Value::as_i64));
|
||||||
|
sync_session_renderer_active_flags(&mut state);
|
||||||
|
}
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED" => {
|
||||||
|
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let player_state = payload.get("player_state");
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
|
||||||
|
|
||||||
|
if let Some(playing_state) = player_state
|
||||||
|
.and_then(|value| value.get("playing_state"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
{
|
||||||
|
renderer_state.playing_state = Some(playing_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current_position_ms) = player_state
|
||||||
|
.and_then(|value| value.get("current_position"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| u64::try_from(value).ok())
|
||||||
|
{
|
||||||
|
renderer_state.current_position_ms = Some(current_position_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current_queue_item_id) = player_state
|
||||||
|
.and_then(|value| value.get("current_queue_item_id"))
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
{
|
{
|
||||||
state.session.active_renderer_id = Some(id as i32);
|
renderer_state.current_queue_item_id = u64::try_from(current_queue_item_id).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
|
}
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED" => {
|
||||||
|
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(volume) = payload
|
||||||
|
.get("volume")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
|
||||||
|
renderer_state.volume = Some(volume);
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
|
}
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED" => {
|
||||||
|
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(muted) = payload.get("value").and_then(Value::as_bool) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
|
||||||
|
renderer_state.muted = Some(muted);
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
|
}
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_MAX_AUDIO_QUALITY_CHANGED" => {
|
||||||
|
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(max_audio_quality) = payload
|
||||||
|
.get("max_audio_quality")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id as i32);
|
||||||
|
renderer_state.max_audio_quality = Some(max_audio_quality);
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
|
}
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET" => {
|
||||||
|
let Some(loop_mode) = payload
|
||||||
|
.get("loop_mode")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(active_renderer_id) = state.session.active_renderer_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut state, active_renderer_id);
|
||||||
|
renderer_state.loop_mode = Some(loop_mode);
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -884,6 +1084,10 @@ impl QconnectServiceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn renderer_snapshot(&self) -> Result<QConnectRendererState, String> {
|
pub async fn renderer_snapshot(&self) -> Result<QConnectRendererState, String> {
|
||||||
|
if let Some((renderer_snapshot, _, _)) = self.effective_remote_renderer_snapshot().await? {
|
||||||
|
return Ok(renderer_snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
let app = {
|
let app = {
|
||||||
let guard = self.inner.lock().await;
|
let guard = self.inner.lock().await;
|
||||||
guard
|
guard
|
||||||
@@ -910,6 +1114,90 @@ impl QconnectServiceState {
|
|||||||
Ok(state.session.clone())
|
Ok(state.session.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn effective_remote_renderer_snapshot(
|
||||||
|
&self,
|
||||||
|
) -> Result<Option<(QConnectRendererState, QConnectQueueState, QconnectSessionState)>, String> {
|
||||||
|
let (app, sync_state) = {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let Some(runtime) = guard.runtime.as_ref() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
(Arc::clone(&runtime.app), Arc::clone(&runtime.sync_state))
|
||||||
|
};
|
||||||
|
|
||||||
|
let queue = app.queue_state_snapshot().await;
|
||||||
|
let state = sync_state.lock().await;
|
||||||
|
let session = state.session.clone();
|
||||||
|
if !is_peer_renderer_active(&session) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(active_renderer_id) = session.active_renderer_id else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer_state = state
|
||||||
|
.session_renderer_states
|
||||||
|
.get(&active_renderer_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(QconnectSessionRendererState {
|
||||||
|
active: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let renderer = build_session_renderer_snapshot(&queue, Some(&renderer_state));
|
||||||
|
|
||||||
|
Ok(Some((renderer, queue, session)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prime_remote_renderer_state(
|
||||||
|
&self,
|
||||||
|
queue_item_id: u64,
|
||||||
|
playing_state: Option<i32>,
|
||||||
|
current_position_ms: Option<u64>,
|
||||||
|
) {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let Some(runtime) = guard.runtime.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sync_state = runtime.sync_state.lock().await;
|
||||||
|
let Some(active_renderer_id) = sync_state.session.active_renderer_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if sync_state.session.local_renderer_id == Some(active_renderer_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut sync_state, active_renderer_id);
|
||||||
|
renderer_state.current_queue_item_id = Some(queue_item_id);
|
||||||
|
if let Some(playing_state) = playing_state {
|
||||||
|
renderer_state.playing_state = Some(playing_state);
|
||||||
|
}
|
||||||
|
if let Some(current_position_ms) = current_position_ms {
|
||||||
|
renderer_state.current_position_ms = Some(current_position_ms);
|
||||||
|
}
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prime_remote_renderer_playing_state(&self, playing_state: i32) {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let Some(runtime) = guard.runtime.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sync_state = runtime.sync_state.lock().await;
|
||||||
|
let Some(active_renderer_id) = sync_state.session.active_renderer_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if sync_state.session.local_renderer_id == Some(active_renderer_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderer_state = ensure_session_renderer_state(&mut sync_state, active_renderer_id);
|
||||||
|
renderer_state.playing_state = Some(playing_state);
|
||||||
|
renderer_state.updated_at_ms = qconnect_now_ms();
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_renderer_report(
|
pub async fn send_renderer_report(
|
||||||
&self,
|
&self,
|
||||||
report: RendererReport,
|
report: RendererReport,
|
||||||
@@ -1068,28 +1356,29 @@ impl QconnectServiceState {
|
|||||||
direction: QconnectRemoteSkipDirection,
|
direction: QconnectRemoteSkipDirection,
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let (app, session) = {
|
let remote_context = self.effective_remote_renderer_snapshot().await?;
|
||||||
let guard = self.inner.lock().await;
|
let Some((renderer, queue, session)) = remote_context else {
|
||||||
let Some(runtime) = guard.runtime.as_ref() else {
|
let (active_renderer_id, local_renderer_id, renderer_count, reason) = {
|
||||||
return Ok(false);
|
let guard = self.inner.lock().await;
|
||||||
|
let Some(runtime) = guard.runtime.as_ref() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let session = runtime.sync_state.lock().await.session.clone();
|
||||||
|
let reason = if session.active_renderer_id.is_none() {
|
||||||
|
"missing_active_renderer_id"
|
||||||
|
} else if session.local_renderer_id.is_none() {
|
||||||
|
"missing_local_renderer_id"
|
||||||
|
} else {
|
||||||
|
"active_renderer_is_local"
|
||||||
|
};
|
||||||
|
(
|
||||||
|
session.active_renderer_id,
|
||||||
|
session.local_renderer_id,
|
||||||
|
session.renderers.len(),
|
||||||
|
reason,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let session = runtime.sync_state.lock().await.session.clone();
|
|
||||||
(Arc::clone(&runtime.app), session)
|
|
||||||
};
|
|
||||||
|
|
||||||
let active_renderer_id = session.active_renderer_id;
|
|
||||||
let local_renderer_id = session.local_renderer_id;
|
|
||||||
let early_return_reason = if active_renderer_id.is_none() {
|
|
||||||
Some("missing_active_renderer_id")
|
|
||||||
} else if local_renderer_id.is_none() {
|
|
||||||
Some("missing_local_renderer_id")
|
|
||||||
} else if active_renderer_id == local_renderer_id {
|
|
||||||
Some("active_renderer_is_local")
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(reason) = early_return_reason {
|
|
||||||
emit_qconnect_diagnostic(
|
emit_qconnect_diagnostic(
|
||||||
app_handle,
|
app_handle,
|
||||||
"qconnect:controller_skip_handoff",
|
"qconnect:controller_skip_handoff",
|
||||||
@@ -1102,14 +1391,14 @@ impl QconnectServiceState {
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
"active_renderer_id": active_renderer_id,
|
"active_renderer_id": active_renderer_id,
|
||||||
"local_renderer_id": local_renderer_id,
|
"local_renderer_id": local_renderer_id,
|
||||||
"renderer_count": session.renderers.len(),
|
"renderer_count": renderer_count,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
let queue = app.queue_state_snapshot().await;
|
let active_renderer_id = session.active_renderer_id;
|
||||||
let renderer = app.renderer_state_snapshot().await;
|
let local_renderer_id = session.local_renderer_id;
|
||||||
let resolution = resolve_controller_queue_item_from_snapshots(&queue, &renderer, direction);
|
let resolution = resolve_controller_queue_item_from_snapshots(&queue, &renderer, direction);
|
||||||
|
|
||||||
let diagnostic_payload = json!({
|
let diagnostic_payload = json!({
|
||||||
@@ -1165,6 +1454,12 @@ impl QconnectServiceState {
|
|||||||
|
|
||||||
self.send_command(QueueCommandType::CtrlSrvrSetPlayerState, payload)
|
self.send_command(QueueCommandType::CtrlSrvrSetPlayerState, payload)
|
||||||
.await?;
|
.await?;
|
||||||
|
self.prime_remote_renderer_state(
|
||||||
|
target_queue_item_id,
|
||||||
|
renderer.playing_state,
|
||||||
|
Some(0),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
emit_qconnect_diagnostic(
|
emit_qconnect_diagnostic(
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -1180,28 +1475,29 @@ impl QconnectServiceState {
|
|||||||
&self,
|
&self,
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let (app, session) = {
|
let remote_context = self.effective_remote_renderer_snapshot().await?;
|
||||||
let guard = self.inner.lock().await;
|
let Some((renderer, _, session)) = remote_context else {
|
||||||
let Some(runtime) = guard.runtime.as_ref() else {
|
let (active_renderer_id, local_renderer_id, renderer_count, reason) = {
|
||||||
return Ok(false);
|
let guard = self.inner.lock().await;
|
||||||
|
let Some(runtime) = guard.runtime.as_ref() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let session = runtime.sync_state.lock().await.session.clone();
|
||||||
|
let reason = if session.active_renderer_id.is_none() {
|
||||||
|
"missing_active_renderer_id"
|
||||||
|
} else if session.local_renderer_id.is_none() {
|
||||||
|
"missing_local_renderer_id"
|
||||||
|
} else {
|
||||||
|
"active_renderer_is_local"
|
||||||
|
};
|
||||||
|
(
|
||||||
|
session.active_renderer_id,
|
||||||
|
session.local_renderer_id,
|
||||||
|
session.renderers.len(),
|
||||||
|
reason,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let session = runtime.sync_state.lock().await.session.clone();
|
|
||||||
(Arc::clone(&runtime.app), session)
|
|
||||||
};
|
|
||||||
|
|
||||||
let active_renderer_id = session.active_renderer_id;
|
|
||||||
let local_renderer_id = session.local_renderer_id;
|
|
||||||
let early_return_reason = if active_renderer_id.is_none() {
|
|
||||||
Some("missing_active_renderer_id")
|
|
||||||
} else if local_renderer_id.is_none() {
|
|
||||||
Some("missing_local_renderer_id")
|
|
||||||
} else if active_renderer_id == local_renderer_id {
|
|
||||||
Some("active_renderer_is_local")
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(reason) = early_return_reason {
|
|
||||||
emit_qconnect_diagnostic(
|
emit_qconnect_diagnostic(
|
||||||
app_handle,
|
app_handle,
|
||||||
"qconnect:toggle_play_handoff",
|
"qconnect:toggle_play_handoff",
|
||||||
@@ -1210,13 +1506,14 @@ impl QconnectServiceState {
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
"active_renderer_id": active_renderer_id,
|
"active_renderer_id": active_renderer_id,
|
||||||
"local_renderer_id": local_renderer_id,
|
"local_renderer_id": local_renderer_id,
|
||||||
"renderer_count": session.renderers.len(),
|
"renderer_count": renderer_count,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
let renderer = app.renderer_state_snapshot().await;
|
let active_renderer_id = session.active_renderer_id;
|
||||||
|
let local_renderer_id = session.local_renderer_id;
|
||||||
let next_playing_state = match renderer.playing_state {
|
let next_playing_state = match renderer.playing_state {
|
||||||
Some(PLAYING_STATE_PLAYING) => PLAYING_STATE_PAUSED,
|
Some(PLAYING_STATE_PLAYING) => PLAYING_STATE_PAUSED,
|
||||||
_ => PLAYING_STATE_PLAYING,
|
_ => PLAYING_STATE_PLAYING,
|
||||||
@@ -1231,6 +1528,8 @@ impl QconnectServiceState {
|
|||||||
|
|
||||||
self.send_command(QueueCommandType::CtrlSrvrSetPlayerState, payload)
|
self.send_command(QueueCommandType::CtrlSrvrSetPlayerState, payload)
|
||||||
.await?;
|
.await?;
|
||||||
|
self.prime_remote_renderer_playing_state(next_playing_state)
|
||||||
|
.await;
|
||||||
|
|
||||||
emit_qconnect_diagnostic(
|
emit_qconnect_diagnostic(
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -1320,6 +1619,12 @@ impl QconnectServiceState {
|
|||||||
|
|
||||||
self.send_command(QueueCommandType::CtrlSrvrSetPlayerState, payload)
|
self.send_command(QueueCommandType::CtrlSrvrSetPlayerState, payload)
|
||||||
.await?;
|
.await?;
|
||||||
|
self.prime_remote_renderer_state(
|
||||||
|
target_queue_item_id,
|
||||||
|
Some(PLAYING_STATE_PLAYING),
|
||||||
|
Some(0),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
emit_qconnect_diagnostic(
|
emit_qconnect_diagnostic(
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -1350,7 +1655,11 @@ impl QconnectServiceState {
|
|||||||
tokio::time::sleep(poll_interval).await;
|
tokio::time::sleep(poll_interval).await;
|
||||||
};
|
};
|
||||||
|
|
||||||
let renderer = app.renderer_state_snapshot().await;
|
let renderer = self
|
||||||
|
.effective_remote_renderer_snapshot()
|
||||||
|
.await?
|
||||||
|
.map(|(renderer, _, _)| renderer)
|
||||||
|
.unwrap_or_else(QConnectRendererState::default);
|
||||||
emit_qconnect_diagnostic(
|
emit_qconnect_diagnostic(
|
||||||
app_handle,
|
app_handle,
|
||||||
"qconnect:play_track_handoff",
|
"qconnect:play_track_handoff",
|
||||||
@@ -3530,6 +3839,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_effective_remote_renderer_snapshot_from_session_cursor() {
|
||||||
|
let queue: QConnectQueueState = serde_json::from_value(json!({
|
||||||
|
"version": { "major": 4, "minor": 1 },
|
||||||
|
"queue_items": [
|
||||||
|
{ "track_context_uuid": "ctx", "track_id": 126886862, "queue_item_id": 126886862 },
|
||||||
|
{ "track_context_uuid": "ctx", "track_id": 25584418, "queue_item_id": 1 },
|
||||||
|
{ "track_context_uuid": "ctx", "track_id": 25120807, "queue_item_id": 2 }
|
||||||
|
],
|
||||||
|
"shuffle_mode": false,
|
||||||
|
"shuffle_order": null,
|
||||||
|
"autoplay_mode": false,
|
||||||
|
"autoplay_loading": false,
|
||||||
|
"autoplay_items": [],
|
||||||
|
"updated_at_ms": 0
|
||||||
|
}))
|
||||||
|
.expect("queue state");
|
||||||
|
let renderer_state = super::QconnectSessionRendererState {
|
||||||
|
active: Some(true),
|
||||||
|
playing_state: Some(super::PLAYING_STATE_PLAYING),
|
||||||
|
current_position_ms: Some(19_999),
|
||||||
|
current_queue_item_id: Some(0),
|
||||||
|
updated_at_ms: 12_345,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = super::build_session_renderer_snapshot(&queue, Some(&renderer_state));
|
||||||
|
|
||||||
|
assert_eq!(snapshot.active, Some(true));
|
||||||
|
assert_eq!(snapshot.playing_state, Some(super::PLAYING_STATE_PLAYING));
|
||||||
|
assert_eq!(snapshot.current_position_ms, Some(19_999));
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.current_track.as_ref().map(|item| (item.track_id, item.queue_item_id)),
|
||||||
|
Some((126886862, 0)),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.next_track.as_ref().map(|item| (item.track_id, item.queue_item_id)),
|
||||||
|
Some((25584418, 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_next_queue_item_id_from_shuffle_order() {
|
fn resolves_next_queue_item_id_from_shuffle_order() {
|
||||||
let queue: QConnectQueueState = serde_json::from_value(json!({
|
let queue: QConnectQueueState = serde_json::from_value(json!({
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function isQconnectPeerRendererActive(
|
|||||||
const activeRendererId = sessionSnapshot?.active_renderer_id ?? null;
|
const activeRendererId = sessionSnapshot?.active_renderer_id ?? null;
|
||||||
const localRendererId = sessionSnapshot?.local_renderer_id ?? null;
|
const localRendererId = sessionSnapshot?.local_renderer_id ?? null;
|
||||||
|
|
||||||
if (activeRendererId == null || localRendererId == null) {
|
if (activeRendererId == null || localRendererId == null || activeRendererId < 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-6
@@ -321,6 +321,7 @@
|
|||||||
evaluateQconnectPlaybackReportSkip,
|
evaluateQconnectPlaybackReportSkip,
|
||||||
evaluateQconnectSessionPersistence,
|
evaluateQconnectSessionPersistence,
|
||||||
fetchQconnectRuntimeState,
|
fetchQconnectRuntimeState,
|
||||||
|
isQconnectPeerRendererActive,
|
||||||
isQconnectRemoteModeActive as computeQconnectRemoteModeActive,
|
isQconnectRemoteModeActive as computeQconnectRemoteModeActive,
|
||||||
logQconnectPlaybackReport as appendQconnectPlaybackReport,
|
logQconnectPlaybackReport as appendQconnectPlaybackReport,
|
||||||
qconnectAdmissionReasonKey,
|
qconnectAdmissionReasonKey,
|
||||||
@@ -907,6 +908,30 @@
|
|||||||
let infinitePlayEnabled = $state(false);
|
let infinitePlayEnabled = $state(false);
|
||||||
let sessionPersistEnabled = $state(false);
|
let sessionPersistEnabled = $state(false);
|
||||||
let radioLoading = $state(false);
|
let radioLoading = $state(false);
|
||||||
|
let qconnectRemoteClockMs = $state(Date.now());
|
||||||
|
|
||||||
|
const qconnectPeerRendererActive = $derived(
|
||||||
|
isQconnectPeerRendererActive(qobuzConnectSessionSnapshot)
|
||||||
|
);
|
||||||
|
const effectiveIsPlaying = $derived(
|
||||||
|
qconnectPeerRendererActive
|
||||||
|
? qobuzConnectRendererSnapshot?.playing_state === 2
|
||||||
|
: isPlaying
|
||||||
|
);
|
||||||
|
const effectiveCurrentTime = $derived(
|
||||||
|
qconnectPeerRendererActive
|
||||||
|
? (() => {
|
||||||
|
const remotePositionMs = Math.max(0, qobuzConnectRendererSnapshot?.current_position_ms ?? 0);
|
||||||
|
const remoteUpdatedAtMs = qobuzConnectRendererSnapshot?.updated_at_ms ?? 0;
|
||||||
|
const extrapolatedMs =
|
||||||
|
effectiveIsPlaying && remoteUpdatedAtMs > 0
|
||||||
|
? remotePositionMs + Math.max(0, qconnectRemoteClockMs - remoteUpdatedAtMs)
|
||||||
|
: remotePositionMs;
|
||||||
|
const maxDurationMs = Math.max(0, duration * 1000);
|
||||||
|
return (maxDurationMs > 0 ? Math.min(extrapolatedMs, maxDurationMs) : extrapolatedMs) / 1000;
|
||||||
|
})()
|
||||||
|
: currentTime
|
||||||
|
);
|
||||||
|
|
||||||
// Toast State (from store subscription)
|
// Toast State (from store subscription)
|
||||||
let toast = $state<ToastData | null>(null);
|
let toast = $state<ToastData | null>(null);
|
||||||
@@ -1083,6 +1108,21 @@
|
|||||||
void refreshQobuzConnectRuntimeState();
|
void refreshQobuzConnectRuntimeState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!qconnectPeerRendererActive || !effectiveIsPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qconnectRemoteClockMs = Date.now();
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
qconnectRemoteClockMs = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Navigation wrapper (keeps debug logging)
|
// Navigation wrapper (keeps debug logging)
|
||||||
async function navigateTo(view: string, itemId?: string | number) {
|
async function navigateTo(view: string, itemId?: string | number) {
|
||||||
console.log('navigateTo called with:', view, 'itemId:', itemId, 'current activeView:', activeView);
|
console.log('navigateTo called with:', view, 'itemId:', itemId, 'current activeView:', activeView);
|
||||||
@@ -3605,7 +3645,7 @@
|
|||||||
// Periodic QConnect position reports (every 2s) so controllers see track progress.
|
// Periodic QConnect position reports (every 2s) so controllers see track progress.
|
||||||
// Only fires when connected and playing. queue_item_ids auto-filled by backend.
|
// Only fires when connected and playing. queue_item_ids auto-filled by backend.
|
||||||
const qconnectPositionReportInterval = setInterval(() => {
|
const qconnectPositionReportInterval = setInterval(() => {
|
||||||
if (isQobuzConnectConnected && isPlaying && currentTrack) {
|
if (isQobuzConnectConnected && !qconnectPeerRendererActive && isPlaying && currentTrack) {
|
||||||
if (shouldSkipQconnectPlaybackReport(currentTrack?.id ?? null)) {
|
if (shouldSkipQconnectPlaybackReport(currentTrack?.id ?? null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3907,7 +3947,7 @@
|
|||||||
// QConnect renderer state relay: report state transitions to server.
|
// QConnect renderer state relay: report state transitions to server.
|
||||||
// QConnect protocol uses milliseconds for position/duration.
|
// QConnect protocol uses milliseconds for position/duration.
|
||||||
// queue_item_ids are auto-filled by the backend from renderer state.
|
// queue_item_ids are auto-filled by the backend from renderer state.
|
||||||
if (isQobuzConnectConnected) {
|
if (isQobuzConnectConnected && !qconnectPeerRendererActive) {
|
||||||
const playingState = isPlaying ? 2 : (currentTrack ? 3 : 1);
|
const playingState = isPlaying ? 2 : (currentTrack ? 3 : 1);
|
||||||
const positionMs = Math.round((currentTime || 0) * 1000);
|
const positionMs = Math.round((currentTime || 0) * 1000);
|
||||||
const durationMs = Math.round((playerState.duration || 0) * 1000);
|
const durationMs = Math.round((playerState.duration || 0) * 1000);
|
||||||
@@ -4297,6 +4337,16 @@
|
|||||||
if (needsQueueSync) {
|
if (needsQueueSync) {
|
||||||
syncQueueState();
|
syncQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsQconnectSnapshotRefresh =
|
||||||
|
'QueueUpdated' in payload ||
|
||||||
|
'RendererUpdated' in payload ||
|
||||||
|
'RendererCommandApplied' in payload ||
|
||||||
|
'PendingActionCompleted' in payload ||
|
||||||
|
'SessionManagementEvent' in payload;
|
||||||
|
if (needsQconnectSnapshotRefresh) {
|
||||||
|
void refreshQobuzConnectRuntimeState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (disposed) { unlisten6(); return; }
|
if (disposed) { unlisten6(); return; }
|
||||||
@@ -5256,11 +5306,11 @@
|
|||||||
originalBitDepth={currentTrack.originalBitDepth}
|
originalBitDepth={currentTrack.originalBitDepth}
|
||||||
originalSamplingRate={currentTrack.originalSamplingRate}
|
originalSamplingRate={currentTrack.originalSamplingRate}
|
||||||
format={currentTrack.format}
|
format={currentTrack.format}
|
||||||
{isPlaying}
|
isPlaying={effectiveIsPlaying}
|
||||||
onTogglePlay={togglePlay}
|
onTogglePlay={togglePlay}
|
||||||
onSkipBack={handleSkipBack}
|
onSkipBack={handleSkipBack}
|
||||||
onSkipForward={handleSkipForward}
|
onSkipForward={handleSkipForward}
|
||||||
{currentTime}
|
currentTime={effectiveCurrentTime}
|
||||||
{duration}
|
{duration}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
{volume}
|
{volume}
|
||||||
@@ -5349,11 +5399,11 @@
|
|||||||
originalBitDepth={currentTrack.originalBitDepth}
|
originalBitDepth={currentTrack.originalBitDepth}
|
||||||
originalSamplingRate={currentTrack.originalSamplingRate}
|
originalSamplingRate={currentTrack.originalSamplingRate}
|
||||||
format={currentTrack.format}
|
format={currentTrack.format}
|
||||||
{isPlaying}
|
isPlaying={effectiveIsPlaying}
|
||||||
onTogglePlay={togglePlay}
|
onTogglePlay={togglePlay}
|
||||||
onSkipBack={handleSkipBack}
|
onSkipBack={handleSkipBack}
|
||||||
onSkipForward={handleSkipForward}
|
onSkipForward={handleSkipForward}
|
||||||
{currentTime}
|
currentTime={effectiveCurrentTime}
|
||||||
{duration}
|
{duration}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
{volume}
|
{volume}
|
||||||
|
|||||||
Reference in new issue
Block a user