Enable immediate Sendspin sync delay changes for Cast players (#2823)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Tue, 16 Dec 2025 19:08:20 +0000 (20:08 +0100)
committerGitHub <noreply@github.com>
Tue, 16 Dec 2025 19:08:20 +0000 (20:08 +0100)
music_assistant/controllers/players/player_controller.py
music_assistant/providers/chromecast/player.py

index 611649157d65c096cb96b2e32f141e6523c556a6..c4ca6255ffbebe1ded4a3bccb3ddfcdb2c5b1e4e 100644 (file)
@@ -1774,8 +1774,26 @@ class PlayerController(CoreController):
         await player.on_config_updated()
         player.update_state()
         # if the PlayerQueue was playing, restart playback
-        # TODO: add property to ConfigEntry if it requires a restart of playback on change
+        # TODO: add restart_stream property to ConfigEntry and use that instead of immediate_apply
+        # to check if we need to restart playback
         if not player_disabled and resume_queue and resume_queue.state == PlaybackState.PLAYING:
+            config_entries = await player.get_config_entries()
+            has_value_changes = False
+            all_immediate_apply = True
+            for key in changed_keys:
+                if not key.startswith("values/"):
+                    continue  # skip root values like "enabled", "name"
+                has_value_changes = True
+                actual_key = key.removeprefix("values/")
+                entry = next((e for e in config_entries if e.key == actual_key), None)
+                if entry is None or not entry.immediate_apply:
+                    all_immediate_apply = False
+                    break
+
+            if has_value_changes and all_immediate_apply:
+                # All changed config entries have immediate_apply=True, so no need to restart
+                # the playback
+                return
             # always stop first to ensure the player uses the new config
             await self.mass.player_queues.stop(resume_queue.queue_id)
             self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id, False)
index dcdef06856fc0ca7cfe2ce6a8f70e27d2e4ebb3d..ace7a5f10a1509b868aebdc8f8182f89092a41ee 100644 (file)
@@ -223,7 +223,9 @@ class ChromecastPlayer(Player):
                 )
             )
             if self._last_sent_sync_delay != current_sync_delay:
-                self.mass.create_task(self._send_sendspin_server_url())
+                # Update immediately to prevent duplicate sends from concurrent events
+                self._last_sent_sync_delay = current_sync_delay
+                self.mass.create_task(self._send_sendspin_sync_delay(current_sync_delay))
 
     async def get_config_entries(
         self,
@@ -260,11 +262,13 @@ class ChromecastPlayer(Player):
             label="Sendspin sync delay (ms)",
             description="Static delay in milliseconds to adjust audio synchronization. "
             "Positive values delay playback, negative values advance it. "
-            "Use this to compensate for device-specific audio latency.",
+            "Use this to compensate for device-specific audio latency. "
+            "Changes take effect immediately.",
             required=False,
             default_value=DEFAULT_SENDSPIN_SYNC_DELAY,
             range=(-1000, 1000),
             hidden=not sendspin_available or self.type == PlayerType.GROUP,
+            immediate_apply=True,
         )
 
         # Codec config entry (only visible when sendspin provider is available)
@@ -319,20 +323,27 @@ class ChromecastPlayer(Player):
             )
         )
 
-        # Resend if either value changed
-        if (
-            self._last_sent_sync_delay != current_sync_delay
-            or self._last_sent_codec != current_codec
-        ):
-            self.logger.debug(
-                "Sendspin config changed, resending (sync_delay: %s -> %s, codec: %s -> %s)",
-                self._last_sent_sync_delay,
-                current_sync_delay,
-                self._last_sent_codec,
-                current_codec,
-            )
+        sync_delay_changed = self._last_sent_sync_delay != current_sync_delay
+        codec_changed = self._last_sent_codec != current_codec
+
+        if sync_delay_changed or codec_changed:
+            # Store old values for logging before updating state
+            old_codec = self._last_sent_codec
+            # Update immediately to prevent duplicate sends from concurrent events
+            self._last_sent_sync_delay = current_sync_delay
+            self._last_sent_codec = current_codec
             try:
-                await self._send_sendspin_server_url()
+                if codec_changed:
+                    # Codec changed - need full reconnection
+                    self.logger.debug(
+                        "Sendspin codec changed (%s -> %s), sending full config",
+                        old_codec,
+                        current_codec,
+                    )
+                    await self._send_sendspin_server_url()
+                else:
+                    # Only sync delay changed, don't reconnect, just send updated delay
+                    await self._send_sendspin_sync_delay(current_sync_delay)
             except Exception as err:
                 self.logger.warning("Failed to send updated Sendspin config to Chromecast: %s", err)
 
@@ -895,6 +906,22 @@ class ChromecastPlayer(Player):
         self._last_sent_sync_delay = sync_delay
         self._last_sent_codec = codec
 
+    async def _send_sendspin_sync_delay(self, sync_delay: int) -> None:
+        """Send only the sync delay update to the Cast receiver (no reconnection)."""
+
+        def send_message() -> None:
+            self.cc.socket_client.send_app_message(
+                SENDSPIN_CAST_NAMESPACE,
+                {"syncDelay": sync_delay},
+            )
+
+        self.logger.debug(
+            "Sending Sendspin sync delay update to Cast receiver: syncDelay=%dms",
+            sync_delay,
+        )
+        await self.mass.loop.run_in_executor(None, send_message)
+        self._last_sent_sync_delay = sync_delay
+
     async def _wait_for_sendspin_player(self, timeout: float = 15.0) -> Player | None:
         """Wait for the Sendspin player to connect and become available."""
         start_time = time.time()