From 9170a1b4085d80ce732785f0f6b313334e8c84dc Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 9 Sep 2025 22:43:16 +0200 Subject: [PATCH 01/15] feat: add forwarding channel of the remote and our PlayerState --- connect/src/spirc.rs | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index fec6057c4..19022efcd 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -25,6 +25,7 @@ use crate::{ social_connect_v2::SessionUpdate, transfer_state::TransferState, user_attributes::UserAttributesMutation, + {context_page::ContextPage, player::PlayerState}, }, state::{ context::{ContextType, ResetContext}, @@ -33,7 +34,6 @@ use crate::{ }, }; use futures_util::StreamExt; -use librespot_protocol::context_page::ContextPage; use protobuf::MessageField; use std::{ future::Future, @@ -42,7 +42,10 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; use thiserror::Error; -use tokio::{sync::mpsc, time::sleep}; +use tokio::{ + sync::{broadcast, mpsc}, + time::sleep, +}; #[derive(Debug, Error)] enum SpircError { @@ -111,6 +114,8 @@ struct SpircTask { /// when no other future resolves, otherwise resets the delay update_state: bool, + state_sender: broadcast::Sender, + spirc_id: usize, } @@ -148,6 +153,7 @@ const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200); /// The spotify connect handle pub struct Spirc { commands: mpsc::UnboundedSender, + state_sender: broadcast::Sender, } impl Spirc { @@ -225,6 +231,7 @@ impl Spirc { let _ = session.login5().auth_token().await?; let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let (state_tx, _) = broadcast::channel(1); let player_events = player.get_player_event_channel(); @@ -261,10 +268,15 @@ impl Spirc { update_volume: false, update_state: false, + state_sender: state_tx.clone(), + spirc_id, }; - let spirc = Spirc { commands: cmd_tx }; + let spirc = Spirc { + commands: cmd_tx, + state_sender: state_tx, + }; let initial_volume = task.connect_state.device_info().volume; task.connect_state.set_volume(0); @@ -435,6 +447,14 @@ impl Spirc { .commands .send(SpircCommand::Transfer(transfer_request))?) } + + /// Get a channel which sends the [PlayerState] whenever it changes. + /// + /// Forwards the internal [PlayerState] when we are the active device. When we are only + /// a spectator, forwards any [PlayerState] update from the active player. + pub fn get_state_update_channel(&self) -> broadcast::Receiver { + self.state_sender.subscribe() + } } impl SpircTask { @@ -983,6 +1003,17 @@ impl SpircTask { } } + fn emit_state_update(&self, state: Option) { + if self.state_sender.receiver_count() == 0 { + return; + } + + let state = state.unwrap_or_else(|| self.connect_state.player().clone()); + if let Err(why) = self.state_sender.send(state) { + warn!("couldn't emit state because: {why}") + } + } + async fn handle_cluster_update( &mut self, mut cluster_update: ClusterUpdate, @@ -995,7 +1026,7 @@ impl SpircTask { cluster_update.cluster.active_device_id ); - if let Some(cluster) = cluster_update.cluster.take() { + if let Some(mut cluster) = cluster_update.cluster.take() { let became_inactive = self.connect_state.is_active() && cluster.active_device_id != self.session.device_id(); if became_inactive { @@ -1007,6 +1038,8 @@ impl SpircTask { // background: when another device sends a connect-state update, some player's position de-syncs // tried: providing session_id, playback_id, track-metadata "track_player" self.update_state = true; + } else if let Some(state) = cluster.player_state.take() { + self.emit_state_update(Some(state)) } } else if self.connect_state.is_active() { self.connect_state.became_inactive(&self.session).await?; @@ -1903,6 +1936,8 @@ impl SpircTask { self.connect_state.set_now(self.now_ms() as u64); + self.emit_state_update(None); + self.connect_state .send_state(&self.session) .await From ea633fc82ebc1b6a76b384f8d5624b349aa15630 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 08:53:14 +0000 Subject: [PATCH 02/15] add broadcast/watch channel types for updates/state --- connect/src/spirc.rs | 105 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 19022efcd..787d1711e 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -17,7 +17,10 @@ use crate::{ player::{Player, PlayerEvent, PlayerEventChannel, QueueTrack}, }, protocol::{ - connect::{Cluster, ClusterUpdate, LogoutCommand, SetVolumeCommand}, + connect::{ + Cluster, ClusterUpdate, ClusterUpdateReason as ServerClusterUpdateReason, + LogoutCommand, SetVolumeCommand, + }, context::Context, explicit_content_pubsub::UserAttributesUpdate, player::ProvidedTrack, @@ -36,6 +39,7 @@ use crate::{ use futures_util::StreamExt; use protobuf::MessageField; use std::{ + collections::HashMap, future::Future, sync::Arc, sync::atomic::{AtomicUsize, Ordering}, @@ -43,7 +47,7 @@ use std::{ }; use thiserror::Error; use tokio::{ - sync::{broadcast, mpsc}, + sync::{broadcast, mpsc, watch}, time::sleep, }; @@ -72,6 +76,103 @@ impl From for Error { } } +/// Information about a device in the cluster +#[derive(Debug, Clone)] +pub struct DeviceInfo { + /// Unique device identifier + pub device_id: String, + /// Human-readable device name + pub device_alias: String, + /// Device type (e.g., "Speaker", "Phone") + pub device_type: String, + /// Volume level 0-100 + pub volume: u32, + /// Whether this is the currently active device + pub is_active: bool, +} + +/// Current state of the device cluster (all known devices) +#[derive(Debug, Clone)] +pub struct ClusterState { + /// Map of all known devices by device_id + pub devices: HashMap, + /// Currently active device ID (if any) + pub active_device_id: Option, +} + +/// Queue information (previous and next tracks) +#[derive(Debug, Clone)] +pub struct QueueList { + /// Previous tracks in the queue (as URIs) + pub prev_tracks: Vec, + /// Next tracks in the queue (as URIs) + pub next_tracks: Vec, +} + +/// Semantic reason for cluster updates +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClusterUpdateReason { + /// Device list changed + DeviceListChanged, + /// Active device switched + ActiveDeviceChanged, + /// Device state changed + DeviceStateChanged, + /// Device info changed + DeviceInfoChanged, +} + +/// Event emitted when cluster state changes +#[derive(Debug, Clone)] +pub struct ClusterUpdateEvent { + /// Device ID that changed + pub device_id: String, + /// Reason for the update + pub reason: ClusterUpdateReason, +} + +/// Semantic reasons for queue updates +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueueUpdateReason { + /// Previous tracks changed + PrevTracksChanged, + /// Next tracks changed + NextTracksChanged, +} + +/// Event emitted when queue changes +#[derive(Debug, Clone)] +pub struct QueueUpdateEvent { + /// Reason for the queue update + pub reason: QueueUpdateReason, +} + +/// Semantic reasons for player state updates +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerUpdateReason { + /// Track changed + TrackChanged, + /// Position changed + PositionChanged, + /// Play/pause state changed + PlayPauseChanged, + /// Shuffle mode changed + ShuffleChanged, + /// Repeat mode changed + RepeatChanged, + /// Context changed + ContextChanged, + /// Other state change + Other, +} + +/// Emitted when player state changes +#[derive(Debug, Clone)] +pub struct PlayerUpdateEvent { + /// Reason for the player update + pub reason: PlayerUpdateReason, +} + struct SpircTask { player: Arc, mixer: Arc, From fb0203b5716410fcef2020a540af0a1f80530921 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 09:01:07 +0000 Subject: [PATCH 03/15] add broadcast/watch channel fields and public api --- connect/src/spirc.rs | 79 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 787d1711e..056e7e2ce 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -215,7 +215,14 @@ struct SpircTask { /// when no other future resolves, otherwise resets the delay update_state: bool, - state_sender: broadcast::Sender, + player_update_sender: broadcast::Sender, + cluster_update_sender: broadcast::Sender, + queue_update_sender: broadcast::Sender, + player_state_sender: watch::Sender>, + cluster_state_sender: watch::Sender, + queue_list_sender: watch::Sender, + last_active_device_id: Option, + last_player_state: Option, spirc_id: usize, } @@ -254,7 +261,12 @@ const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200); /// The spotify connect handle pub struct Spirc { commands: mpsc::UnboundedSender, - state_sender: broadcast::Sender, + player_update_sender: broadcast::Sender, + cluster_update_sender: broadcast::Sender, + queue_update_sender: broadcast::Sender, + player_state_sender: watch::Sender>, + cluster_state_sender: watch::Sender, + queue_list_sender: watch::Sender, } impl Spirc { @@ -332,7 +344,18 @@ impl Spirc { let _ = session.login5().auth_token().await?; let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let (state_tx, _) = broadcast::channel(1); + let (player_update_sender_tx, _) = broadcast::channel(1); + let (cluster_update_sender_tx, _) = broadcast::channel(1); + let (queue_update_sender_tx, _) = broadcast::channel(1); + let (player_state_sender_tx, _) = watch::channel(None); + let (cluster_state_sender_tx, _) = watch::channel(ClusterState { + devices: HashMap::new(), + active_device_id: None, + }); + let (queue_list_sender_tx, _) = watch::channel(QueueList { + prev_tracks: Vec::new(), + next_tracks: Vec::new(), + }); let player_events = player.get_player_event_channel(); @@ -369,14 +392,26 @@ impl Spirc { update_volume: false, update_state: false, - state_sender: state_tx.clone(), + player_update_sender: player_update_tx.clone(), + cluster_update_sender: cluster_update_tx.clone(), + queue_update_sender: queue_update_tx.clone(), + player_state_sender: player_state_sender_tx.clone(), + cluster_state_sender: cluster_state_sender_tx.clone(), + queue_list_sender: queue_list_sender_tx.clone(), + last_active_device_id: None, + last_player_state: None, spirc_id, }; let spirc = Spirc { commands: cmd_tx, - state_sender: state_tx, + player_update_sender: player_update_tx, + cluster_update_sender: cluster_update_tx, + queue_update_sender: queue_update_tx, + player_state_sender: player_state_sender_tx, + cluster_state_sender: cluster_state_sender_tx, + queue_list_sender: queue_list_sender_tx, }; let initial_volume = task.connect_state.device_info().volume; @@ -549,12 +584,34 @@ impl Spirc { .send(SpircCommand::Transfer(transfer_request))?) } - /// Get a channel which sends the [PlayerState] whenever it changes. - /// - /// Forwards the internal [PlayerState] when we are the active device. When we are only - /// a spectator, forwards any [PlayerState] update from the active player. - pub fn get_state_update_channel(&self) -> broadcast::Receiver { - self.state_sender.subscribe() + /// Get a channel which sends lightweight playback state updates. + pub fn get_player_update_channel(&self) -> broadcast::Receiver { + self.player_update_sender.subscribe() + } + + /// Get a channel which sends device topology changes (devices appearing/disappearing, active device changes). + pub fn get_cluster_update_channel(&self) -> broadcast::Receiver { + self.cluster_update_sender.subscribe() + } + + /// Get a channel which sends queue change events when prev/next tracks differ. + pub fn get_queue_update_channel(&self) -> broadcast::Receiver { + self.queue_update_sender.subscribe() + } + + /// Watch the current player state (full PlayerState) + pub fn watch_player_state(&self) -> watch::Receiver> { + self.player_state_sender.subscribe() + } + + /// Watch the current cluster state (all devices and active device) + pub fn watch_cluster_state(&self) -> watch::Receiver { + self.cluster_state_sender.subscribe() + } + + /// Watch the current queue list (previous and next tracks) + pub fn watch_queue_list(&self) -> watch::Receiver { + self.queue_list_sender.subscribe() } } From 1f4f68ea051007d53edf074e6ed8d20cfd8e2ae7 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 09:32:08 +0000 Subject: [PATCH 04/15] emit player updates to its broadcast channel semantically diff for player changes. also emit cluster and queue updates to their broadcast channels --- connect/src/spirc.rs | 120 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 056e7e2ce..0354de5d3 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1161,14 +1161,126 @@ impl SpircTask { } } - fn emit_state_update(&self, state: Option) { - if self.state_sender.receiver_count() == 0 { + fn emit_player_update(&self, state: Option, last_state: Option<&PlayerState>) { + if self.player_update_sender.receiver_count() == 0 + && self.player_state_sender.receiver_count() == 0 + { return; } let state = state.unwrap_or_else(|| self.connect_state.player().clone()); - if let Err(why) = self.state_sender.send(state) { - warn!("couldn't emit state because: {why}") + + // Determine the reason for the update by diffing with last state + let reason = if let Some(last) = last_state { + // Check in priority order: track > play/pause > shuffle > repeat > seek + let new_track_uri = state + .track + .as_ref() + .map(|t| t.uri.clone()) + .unwrap_or_default(); + let old_track_uri = last + .track + .as_ref() + .map(|t| t.uri.clone()) + .unwrap_or_default(); + if new_track_uri != old_track_uri { + let _ = self.player_state_sender.send(Some(state)); + let _ = self.player_update_sender.send(PlayerUpdateEvent { + reason: PlayerUpdateReason::TrackChanged, + }); + return; + } + + let new_is_playing = state.is_playing && !state.is_paused; + let old_is_playing = last.is_playing && !last.is_paused; + if new_is_playing != old_is_playing { + let _ = self.player_state_sender.send(Some(state)); + let _ = self.player_update_sender.send(PlayerUpdateEvent { + reason: PlayerUpdateReason::PlayPauseChanged, + }); + return; + } + + let new_shuffle = state + .options + .as_ref() + .map(|o| o.shuffling_context) + .unwrap_or(false); + let old_shuffle = last + .options + .as_ref() + .map(|o| o.shuffling_context) + .unwrap_or(false); + if new_shuffle != old_shuffle { + let _ = self.player_state_sender.send(Some(state)); + let _ = self.player_update_sender.send(PlayerUpdateEvent { + reason: PlayerUpdateReason::ShuffleChanged, + }); + return; + } + + let new_repeat = state + .options + .as_ref() + .map(|o| (o.repeating_context, o.repeating_track)); + let old_repeat = last + .options + .as_ref() + .map(|o| (o.repeating_context, o.repeating_track)); + if new_repeat != old_repeat { + let _ = self.player_state_sender.send(Some(state)); + let _ = self.player_update_sender.send(PlayerUpdateEvent { + reason: PlayerUpdateReason::RepeatChanged, + }); + return; + } + + if state.context_uri != last.context_uri { + let _ = self.player_state_sender.send(Some(state)); + let _ = self.player_update_sender.send(PlayerUpdateEvent { + reason: PlayerUpdateReason::ContextChanged, + }); + return; + } + + // Detect seek: position jump (not just natural progress) + let position_diff = + (state.position_as_of_timestamp - last.position_as_of_timestamp).abs(); + if position_diff > 5000 { + // threshold: 5 seconds + PlayerUpdateReason::PositionChanged + } else { + // No significant change detected + PlayerUpdateReason::Other + } + } else { + // No previous state (initial state) + PlayerUpdateReason::Other + }; + + let _ = self.player_state_sender.send(Some(state)); + if let Err(why) = self.player_update_sender.send(PlayerUpdateEvent { reason }) { + warn!("couldn't emit player update because: {why}") + } + } + + fn emit_cluster_update(&self, event: ClusterUpdateEvent) { + if self.cluster_update_sender.receiver_count() == 0 { + return; + } + + if let Err(why) = self.cluster_update_sender.send(event) { + warn!("couldn't emit cluster transition because: {why}") + } + } + + fn emit_queue_update(&self, event: QueueUpdateEvent) { + if self.queue_update_sender.receiver_count() == 0 { + return; + } + + if let Err(why) = self.queue_update_sender.send(event) { + warn!("couldn't emit queue update because: {why}") } } From 0f39bdb623e3b8735f7a42f4aca1145566571781 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 09:33:28 +0000 Subject: [PATCH 05/15] emit cluster updates/states to its broadcast/watch channels use server cluster update reasons for cluster changes. also emit cluster topology snapshots with all devices and active device --- connect/src/spirc.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 0354de5d3..e88a96e40 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1297,6 +1297,74 @@ impl SpircTask { ); if let Some(mut cluster) = cluster_update.cluster.take() { + if let Ok(reason_enum) = reason { + match reason_enum { + ServerClusterUpdateReason::DEVICE_NEW_CONNECTION + | ServerClusterUpdateReason::NEW_DEVICE_APPEARED + | ServerClusterUpdateReason::DEVICES_DISAPPEARED => { + for device_id in &cluster_update.devices_that_changed { + self.emit_cluster_update(ClusterUpdateEvent { + reason: crate::spirc::ClusterUpdateReason::DeviceListChanged, + device_id: device_id.clone(), + }); + } + } + ServerClusterUpdateReason::DEVICE_ALIAS_CHANGED + | ServerClusterUpdateReason::DEVICE_VOLUME_CHANGED => { + for device_id in &cluster_update.devices_that_changed { + self.emit_cluster_update(ClusterUpdateEvent { + reason: crate::spirc::ClusterUpdateReason::DeviceInfoChanged, + device_id: device_id.clone(), + }); + } + } + ServerClusterUpdateReason::DEVICE_STATE_CHANGED => { + for device_id in &cluster_update.devices_that_changed { + self.emit_cluster_update(ClusterUpdateEvent { + reason: crate::spirc::ClusterUpdateReason::DeviceStateChanged, + device_id: device_id.clone(), + }); + } + } + _ => {} + } + } + + // Check for active device changes + let new_active_device_id = cluster.active_device_id.clone(); + if Some(new_active_device_id.clone()) != self.last_active_device_id { + self.emit_cluster_update(ClusterUpdateEvent { + reason: crate::spirc::ClusterUpdateReason::ActiveDeviceChanged, + device_id: new_active_device_id.clone(), + }); + self.last_active_device_id = Some(new_active_device_id); + } + + // Sync device state to cluster_state_sender (after emitting events) + let devices: HashMap = cluster + .device + .iter() + .map(|(_, device)| { + let info = DeviceInfo { + device_id: device.device_id.clone(), + device_alias: device.name.clone(), + device_type: format!("{:?}", device.device_type), + volume: device.volume as u32, + is_active: device.device_id == cluster.active_device_id, + }; + (info.device_id.clone(), info) + }) + .collect(); + + let _ = self.cluster_state_sender.send(ClusterState { + devices, + active_device_id: if cluster.active_device_id.is_empty() { + None + } else { + Some(cluster.active_device_id.clone()) + }, + }); + let became_inactive = self.connect_state.is_active() && cluster.active_device_id != self.session.device_id(); if became_inactive { From 5cd8c3e55ca5c84458110e2932db58d858f830c4 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 09:38:07 +0000 Subject: [PATCH 06/15] emit queue updates/lists to its broadcast/watch channels diff prev and next tracks for queue changes. also emit queue list --- connect/src/spirc.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e88a96e40..27fc289ec 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1377,7 +1377,30 @@ impl SpircTask { // tried: providing session_id, playback_id, track-metadata "track_player" self.update_state = true; } else if let Some(state) = cluster.player_state.take() { - self.emit_state_update(Some(state)) + if let Some(last_state) = &self.last_player_state { + let prev_changed = state.prev_tracks != last_state.prev_tracks; + let next_changed = state.next_tracks != last_state.next_tracks; + if prev_changed || next_changed { + let queue_list = QueueList { + prev_tracks: state.prev_tracks.iter().map(|t| t.uri.clone()).collect(), + next_tracks: state.next_tracks.iter().map(|t| t.uri.clone()).collect(), + }; + let _ = self.queue_list_sender.send(queue_list); + + if prev_changed { + self.emit_queue_update(QueueUpdateEvent { + reason: QueueUpdateReason::PrevTracksChanged, + }); + } + if next_changed { + self.emit_queue_update(QueueUpdateEvent { + reason: QueueUpdateReason::NextTracksChanged, + }); + } + } + } + self.emit_player_update(Some(state.clone()), self.last_player_state.as_ref()); + self.last_player_state = Some(state); } } else if self.connect_state.is_active() { self.connect_state.became_inactive(&self.session).await?; From 02e568c877e04be35b5ae049b45394cefe898fad Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 09:51:32 +0000 Subject: [PATCH 07/15] remove redundant emit player state call --- connect/src/spirc.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 27fc289ec..f2db11f65 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -2297,7 +2297,6 @@ impl SpircTask { self.connect_state.set_now(self.now_ms() as u64); - self.emit_state_update(None); self.connect_state .send_state(&self.session) From d9eaa62f3e3216bd87b40c290cbdac512782d8a8 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 10:09:17 +0000 Subject: [PATCH 08/15] fix vars --- connect/src/spirc.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index f2db11f65..c6fba3b31 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -392,9 +392,9 @@ impl Spirc { update_volume: false, update_state: false, - player_update_sender: player_update_tx.clone(), - cluster_update_sender: cluster_update_tx.clone(), - queue_update_sender: queue_update_tx.clone(), + player_update_sender: player_update_sender_tx.clone(), + cluster_update_sender: cluster_update_sender_tx.clone(), + queue_update_sender: queue_update_sender_tx.clone(), player_state_sender: player_state_sender_tx.clone(), cluster_state_sender: cluster_state_sender_tx.clone(), queue_list_sender: queue_list_sender_tx.clone(), @@ -406,9 +406,9 @@ impl Spirc { let spirc = Spirc { commands: cmd_tx, - player_update_sender: player_update_tx, - cluster_update_sender: cluster_update_tx, - queue_update_sender: queue_update_tx, + player_update_sender: player_update_sender_tx, + cluster_update_sender: cluster_update_sender_tx, + queue_update_sender: queue_update_sender_tx, player_state_sender: player_state_sender_tx, cluster_state_sender: cluster_state_sender_tx, queue_list_sender: queue_list_sender_tx, From 7dba9dd3ec78c8e32a72417bb639dcbc28e6b062 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 10:46:03 +0000 Subject: [PATCH 09/15] fix fmt --- connect/src/spirc.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c6fba3b31..64bf5b7f0 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -2297,7 +2297,6 @@ impl SpircTask { self.connect_state.set_now(self.now_ms() as u64); - self.connect_state .send_state(&self.session) .await From b0fef94415e135b7afe4088157cbf0ea709ea267 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 11:24:05 +0000 Subject: [PATCH 10/15] improve seek detection account for elapsed time and play/pause state. also rename to SeekChanged --- connect/src/spirc.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 64bf5b7f0..788643d5b 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -152,8 +152,6 @@ pub struct QueueUpdateEvent { pub enum PlayerUpdateReason { /// Track changed TrackChanged, - /// Position changed - PositionChanged, /// Play/pause state changed PlayPauseChanged, /// Shuffle mode changed @@ -162,6 +160,8 @@ pub enum PlayerUpdateReason { RepeatChanged, /// Context changed ContextChanged, + /// Seek detected + SeekChanged, /// Other state change Other, } @@ -1244,11 +1244,18 @@ impl SpircTask { } // Detect seek: position jump (not just natural progress) - let position_diff = - (state.position_as_of_timestamp - last.position_as_of_timestamp).abs(); - if position_diff > 5000 { - // threshold: 5 seconds - PlayerUpdateReason::PositionChanged + let time_diff = state.timestamp.saturating_sub(last.timestamp); + let expected_position = if state.is_playing { + // Account for natural progression if playing + last.position_as_of_timestamp + time_diff + } else { + // If paused, position shouldn't change + last.position_as_of_timestamp + }; + + let position_delta = (state.position_as_of_timestamp as i64 - expected_position as i64).abs(); + if position_delta > 5000 { + PlayerUpdateReason::SeekChanged } else { // No significant change detected PlayerUpdateReason::Other From e26841a6c04864033f0e4daf29fb52bbed08fd17 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 11:33:08 +0000 Subject: [PATCH 11/15] emit queue list on first update --- connect/src/spirc.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 788643d5b..82e38de98 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1405,6 +1405,12 @@ impl SpircTask { }); } } + } else { + let queue_list = QueueList { + prev_tracks: state.prev_tracks.iter().map(|t| t.uri.clone()).collect(), + next_tracks: state.next_tracks.iter().map(|t| t.uri.clone()).collect(), + }; + let _ = self.queue_list_sender.send(queue_list); } self.emit_player_update(Some(state.clone()), self.last_player_state.as_ref()); self.last_player_state = Some(state); From dab6b2d07704c318c9ab0cac6313b0471e788896 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 11:40:18 +0000 Subject: [PATCH 12/15] normalize empty active device id --- connect/src/spirc.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 82e38de98..47fc43819 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1338,13 +1338,17 @@ impl SpircTask { } // Check for active device changes - let new_active_device_id = cluster.active_device_id.clone(); - if Some(new_active_device_id.clone()) != self.last_active_device_id { + let new_active_device_id = if cluster.active_device_id.is_empty() { + None + } else { + Some(cluster.active_device_id.clone()) + }; + if new_active_device_id != self.last_active_device_id { self.emit_cluster_update(ClusterUpdateEvent { reason: crate::spirc::ClusterUpdateReason::ActiveDeviceChanged, - device_id: new_active_device_id.clone(), + device_id: new_active_device_id.clone().unwrap_or_default(), }); - self.last_active_device_id = Some(new_active_device_id); + self.last_active_device_id = new_active_device_id; } // Sync device state to cluster_state_sender (after emitting events) From 31ea6b2a871e0914580b9099ea44216e9fbc4f36 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 11:54:08 +0000 Subject: [PATCH 13/15] emit local player state when device is active --- connect/src/spirc.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 47fc43819..05a21cb4a 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1060,6 +1060,28 @@ impl SpircTask { } self.update_state = true; + + // Emit local player state to watch channels if this device is active + if self.connect_state.is_active() { + let player_state = self.connect_state.player().clone(); + let _ = self.player_state_sender.send(Some(player_state.clone())); + + // Also update queue list from local state + let queue_list = QueueList { + prev_tracks: player_state + .prev_tracks + .iter() + .map(|t| t.uri.clone()) + .collect(), + next_tracks: player_state + .next_tracks + .iter() + .map(|t| t.uri.clone()) + .collect(), + }; + let _ = self.queue_list_sender.send(queue_list); + } + Ok(()) } From 803b92a112b05c6a092c482cbf201ab4f873741a Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 11:55:46 +0000 Subject: [PATCH 14/15] fix fmt --- connect/src/spirc.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 05a21cb4a..9122f1b31 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1275,7 +1275,8 @@ impl SpircTask { last.position_as_of_timestamp }; - let position_delta = (state.position_as_of_timestamp as i64 - expected_position as i64).abs(); + let position_delta = + (state.position_as_of_timestamp as i64 - expected_position as i64).abs(); if position_delta > 5000 { PlayerUpdateReason::SeekChanged } else { From 085c724941be88db2fe9eed50e4c1ab0d196fef1 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Sat, 18 Apr 2026 12:09:16 +0000 Subject: [PATCH 15/15] satisfy mr clippy --- connect/src/spirc.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9122f1b31..85ad6a072 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1276,7 +1276,7 @@ impl SpircTask { }; let position_delta = - (state.position_as_of_timestamp as i64 - expected_position as i64).abs(); + (state.position_as_of_timestamp - expected_position).abs(); if position_delta > 5000 { PlayerUpdateReason::SeekChanged } else { @@ -1377,13 +1377,13 @@ impl SpircTask { // Sync device state to cluster_state_sender (after emitting events) let devices: HashMap = cluster .device - .iter() - .map(|(_, device)| { + .values() + .map(|device| { let info = DeviceInfo { device_id: device.device_id.clone(), device_alias: device.name.clone(), device_type: format!("{:?}", device.device_type), - volume: device.volume as u32, + volume: device.volume, is_active: device.device_id == cluster.active_device_id, }; (info.device_id.clone(), info)