Add Experimental Sendspin over Cast Support (#2737)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Fri, 5 Dec 2025 16:16:26 +0000 (17:16 +0100)
committerGitHub <noreply@github.com>
Fri, 5 Dec 2025 16:16:26 +0000 (17:16 +0100)
music_assistant/providers/chromecast/constants.py
music_assistant/providers/chromecast/player.py

index af26e017cb934a0c158f2f8b958bfb13e0fa9769..07bb79d2f1db939468bd151afddac437e3319da9 100644 (file)
@@ -13,7 +13,11 @@ from music_assistant.constants import (
 
 MASS_APP_ID = "C35B0678"
 APP_MEDIA_RECEIVER = "CC1AD845"
+SENDSPIN_CAST_APP_ID = "938CBF87"
+SENDSPIN_CAST_NAMESPACE = "urn:x-cast:sendspin"
 CONF_USE_MASS_APP = "use_mass_app"
+CONF_USE_SENDSPIN_MODE = "use_sendspin_mode"
+CONF_SENDSPIN_SYNC_DELAY = "sendspin_sync_delay"
 
 CAST_PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_OUTPUT_CODEC,
index 309d8f2908916dcb6caf3e75747bcacf46426b62..fbb4fe3bf8b37bac8e35186f899e9e1d775dc404 100644 (file)
@@ -11,7 +11,15 @@ from music_assistant_models.config_entries import ConfigEntry
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigValueType
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
+    from music_assistant_models.event import MassEvent
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    EventType,
+    MediaType,
+    PlaybackState,
+    PlayerFeature,
+    PlayerType,
+)
 from music_assistant_models.errors import PlayerUnavailableError
 from music_assistant_models.player import PlayerSource
 from pychromecast import IDLE_APP_ID
@@ -31,13 +39,16 @@ from .constants import (
     CAST_PLAYER_CONFIG_ENTRIES,
     CONF_ENTRY_SAMPLE_RATES_CAST,
     CONF_ENTRY_SAMPLE_RATES_CAST_GROUP,
+    CONF_SENDSPIN_SYNC_DELAY,
     CONF_USE_MASS_APP,
+    CONF_USE_SENDSPIN_MODE,
     MASS_APP_ID,
+    SENDSPIN_CAST_APP_ID,
+    SENDSPIN_CAST_NAMESPACE,
 )
 from .helpers import CastStatusListener, ChromecastInfo
 
 if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ConfigEntry
     from pychromecast import Chromecast
     from pychromecast.controllers.media import MediaStatus
     from pychromecast.controllers.receiver import CastStatus
@@ -108,6 +119,111 @@ class ChromecastPlayer(Player):
             self.mz_controller = mz_controller
         self.cc.start()
 
+        # Chromecast players can optionally use Sendspin for streaming
+        # when the sendspin-over-cast receiver app is used.
+        # Generate a predictable sendspin player id from the chromecast uuid.
+        # Format: "cast-XXXXXXXX" where X is derived from the UUID
+        uuid_str = player_id.replace("-", "")
+        self.sendspin_player_id = f"cast-{uuid_str[:8].lower()}"
+        self._last_sent_sync_delay: int | None = None
+
+        # Subscribe to sendspin player events for state syncing
+        self._on_unload_callbacks.append(
+            self.mass.subscribe(
+                self._on_sendspin_player_event,
+                (EventType.PLAYER_UPDATED,),
+                self.sendspin_player_id,
+            )
+        )
+
+    @property
+    def sendspin_mode_enabled(self) -> bool:
+        """Return if sendspin mode is enabled for the player."""
+        return bool(
+            self.mass.config.get_raw_player_config_value(
+                self.player_id, CONF_USE_SENDSPIN_MODE, False
+            )
+        )
+
+    def get_linked_sendspin_player(self, enabled_only: bool = True) -> Player | None:
+        """Return the linked sendspin player if available/enabled."""
+        if enabled_only and not self.sendspin_mode_enabled:
+            return None
+        if not (sendspin_player := self.mass.players.get(self.sendspin_player_id)):
+            return None
+        if not sendspin_player.available:
+            return None
+        return sendspin_player
+
+    @property
+    def supported_features(self) -> set[PlayerFeature]:
+        """Return the supported features for this player."""
+        try:
+            if self.sendspin_mode_enabled:
+                # Features for Sendspin mode - grouping happens via Sendspin player
+                return {
+                    PlayerFeature.POWER,
+                    PlayerFeature.VOLUME_SET,
+                    PlayerFeature.VOLUME_MUTE,
+                    PlayerFeature.PAUSE,
+                }
+        except Exception:  # noqa: S110
+            pass  # May fail during early initialization
+        return self._attr_supported_features
+
+    def _translate_from_sendspin_player_id(self, sendspin_player_id: str) -> str | None:
+        """Translate a Sendspin player ID back to its Chromecast player ID if applicable."""
+        # Sendspin player IDs for Chromecast are "cast-XXXXXXXX" where X is from UUID
+        if not sendspin_player_id.startswith("cast-"):
+            return None
+        # Search for a Chromecast player with matching sendspin_player_id
+        for player in self.mass.players.all():
+            if hasattr(player, "sendspin_player_id"):
+                if player.sendspin_player_id == sendspin_player_id:
+                    return player.player_id
+        return None
+
+    async def _on_sendspin_player_event(self, event: MassEvent) -> None:
+        """Handle incoming event from linked sendspin player."""
+        if not self.sendspin_mode_enabled:
+            return
+        if event.object_id != self.sendspin_player_id:
+            return
+        # Sync state from sendspin player to this player
+        if sendspin_player := self.get_linked_sendspin_player(False):
+            self._attr_playback_state = sendspin_player.playback_state
+            self._attr_current_media = sendspin_player.current_media
+            self._attr_elapsed_time = sendspin_player.elapsed_time
+            self._attr_elapsed_time_last_updated = sendspin_player.elapsed_time_last_updated
+            # Sync active_source so queue lookup works correctly
+            self._attr_active_source = sendspin_player.active_source
+            # Translate group_members from Sendspin player IDs to Chromecast player IDs
+            translated_members = []
+            for member_id in sendspin_player.group_members:
+                if cc_id := self._translate_from_sendspin_player_id(member_id):
+                    translated_members.append(cc_id)
+                else:
+                    # Keep original if no translation (e.g. non-Chromecast Sendspin player)
+                    translated_members.append(member_id)
+            self._attr_group_members = translated_members
+            # Translate synced_to from Sendspin player ID to Chromecast player ID
+            if sendspin_player.synced_to:
+                self._attr_synced_to = (
+                    self._translate_from_sendspin_player_id(sendspin_player.synced_to)
+                    or sendspin_player.synced_to
+                )
+            else:
+                self._attr_synced_to = None
+            self.update_state()
+            # Check if sync delay config changed and resend if needed
+            current_sync_delay = int(
+                self.mass.config.get_raw_player_config_value(
+                    self.player_id, CONF_SENDSPIN_SYNC_DELAY, -350
+                )
+            )
+            if self._last_sent_sync_delay != current_sync_delay:
+                self.mass.create_task(self._send_sendspin_server_url())
+
     async def get_config_entries(
         self,
         action: str | None = None,
@@ -115,6 +231,41 @@ class ChromecastPlayer(Player):
     ) -> list[ConfigEntry]:
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         base_entries = await super().get_config_entries(action=action, values=values)
+
+        # Check if Sendspin provider is available
+        sendspin_available = any(
+            prov.domain == "sendspin" for prov in self.mass.get_providers("player")
+        )
+
+        # Sendspin mode config entry
+        sendspin_config = ConfigEntry(
+            key=CONF_USE_SENDSPIN_MODE,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable experimental Sendspin mode",
+            description="When enabled, Music Assistant will use the Sendspin protocol "
+            "for synchronized audio streaming instead of the standard Chromecast protocol. "
+            "This allows grouping Chromecast devices with other Sendspin-compatible players "
+            "for multi-room synchronized playback.\n\n"
+            "NOTE: Requires the Sendspin provider to be enabled.",
+            required=False,
+            default_value=False,
+            hidden=not sendspin_available or self.type == PlayerType.GROUP,
+        )
+
+        # Sync delay config entry (only visible when sendspin provider is available)
+        sendspin_sync_delay_config = ConfigEntry(
+            key=CONF_SENDSPIN_SYNC_DELAY,
+            type=ConfigEntryType.INTEGER,
+            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.",
+            required=False,
+            default_value=-300,
+            range=(-1000, 1000),
+            hidden=not sendspin_available or self.type == PlayerType.GROUP,
+        )
+
         if self.type == PlayerType.GROUP:
             return [
                 *base_entries,
@@ -122,10 +273,21 @@ class ChromecastPlayer(Player):
                 CONF_ENTRY_SAMPLE_RATES_CAST_GROUP,
             ]
 
-        return [*base_entries, *CAST_PLAYER_CONFIG_ENTRIES, CONF_ENTRY_SAMPLE_RATES_CAST]
+        return [
+            *base_entries,
+            *CAST_PLAYER_CONFIG_ENTRIES,
+            CONF_ENTRY_SAMPLE_RATES_CAST,
+            sendspin_config,
+            sendspin_sync_delay_config,
+        ]
 
     async def stop(self) -> None:
         """Send STOP command to given player."""
+        if sendspin_player := self.get_linked_sendspin_player(True):
+            # Sendspin mode is active - direct call to stop (NOT cmd_stop to avoid recursion)
+            self.logger.debug("Redirecting STOP command to linked sendspin player.")
+            await sendspin_player.stop()
+            return
         await asyncio.to_thread(self.cc.media_controller.stop)
 
     async def play(self) -> None:
@@ -134,6 +296,13 @@ class ChromecastPlayer(Player):
 
     async def pause(self) -> None:
         """Send PAUSE command to given player."""
+        if self.sendspin_mode_enabled:
+            # In Sendspin mode, there's no native Cast media session to pause.
+            # Sendspin doesn't support pause, so stop the stream instead.
+            if sendspin_player := self.get_linked_sendspin_player(True):
+                self.logger.debug("Sendspin mode: stopping stream (pause not supported)")
+                await sendspin_player.stop()
+            return
         await asyncio.to_thread(self.cc.media_controller.pause)
 
     async def next_track(self) -> None:
@@ -151,7 +320,28 @@ class ChromecastPlayer(Player):
     async def power(self, powered: bool) -> None:
         """Send POWER command to given player."""
         if powered:
-            await self._launch_app()
+            if self.sendspin_mode_enabled:
+                # Launch Sendspin app and connect to server
+                self.logger.info("Powering on with Sendspin mode enabled.")
+                launch_success = await self._launch_sendspin_app()
+                if launch_success:
+                    await asyncio.sleep(1)  # Give app time to initialize
+                    await self._send_sendspin_server_url()
+                    # Wait for the Sendspin player to connect
+                    sendspin_player = await self._wait_for_sendspin_player()
+                    if sendspin_player:
+                        self.logger.info(
+                            "Sendspin player %s connected successfully.",
+                            sendspin_player.player_id,
+                        )
+                    else:
+                        self.logger.warning("Sendspin player did not connect, but app is running.")
+                else:
+                    # Fall back to standard app
+                    self.logger.warning("Sendspin app launch failed, falling back to standard app.")
+                    await self._launch_app()
+            else:
+                await self._launch_app()
             self._attr_active_source = self.player_id
         else:
             self._attr_active_source = None
@@ -173,6 +363,12 @@ class ChromecastPlayer(Player):
         media: PlayerMedia,
     ) -> None:
         """Handle PLAY MEDIA on given player."""
+        if self.sendspin_mode_enabled:
+            # Sendspin mode is enabled, launch sendspin-over-cast app and redirect
+            self.logger.info("Redirecting PLAY_MEDIA command to sendspin mode.")
+            await self._play_media_sendspin(media)
+            return
+
         queuedata = {
             "type": "LOAD",
             "media": self._create_cc_media_item(media),
@@ -408,6 +604,9 @@ class ChromecastPlayer(Player):
             self.display_name,
             status.player_state,
         )
+        # In Sendspin mode, state is synced from the Sendspin player - skip Cast media status
+        if self.sendspin_mode_enabled:
+            return
         # handle player playing from a group
         group_player: ChromecastPlayer | None = None
         if self.active_cast_group is not None:
@@ -556,3 +755,152 @@ class ChromecastPlayer(Player):
             "metadata": metadata,
             "duration": media.duration,
         }
+
+    async def _launch_sendspin_app(self) -> bool:
+        """Launch the sendspin-over-cast receiver app on the Chromecast.
+
+        :return: True if app launched successfully, False otherwise.
+        """
+        event = asyncio.Event()
+        launch_success = False
+
+        if self.cc.app_id == SENDSPIN_CAST_APP_ID:
+            self.logger.debug("Sendspin Cast App already active.")
+            return True
+
+        def launched_callback(success: bool, response: dict[str, Any] | None) -> None:
+            nonlocal launch_success
+            launch_success = success
+            if not success:
+                self.logger.warning("Failed to launch Sendspin Cast App: %s", response)
+            else:
+                self.logger.debug("Sendspin Cast App launched successfully.")
+            self.mass.loop.call_soon_threadsafe(event.set)
+
+        def launch() -> None:
+            # Quit the previous app before starting sendspin receiver
+            if self.cc.app_id is not None:
+                self.cc.quit_app()
+            self.logger.info(
+                "Launching Sendspin Cast App %s on %s.",
+                SENDSPIN_CAST_APP_ID,
+                self.display_name,
+            )
+            self.cc.socket_client.receiver_controller.launch_app(
+                SENDSPIN_CAST_APP_ID,
+                force_launch=True,
+                callback_function=launched_callback,
+            )
+
+        await self.mass.loop.run_in_executor(None, launch)
+        try:
+            await asyncio.wait_for(event.wait(), timeout=10.0)
+        except TimeoutError:
+            self.logger.error("Timeout waiting for Sendspin Cast App to launch.")
+            return False
+        return launch_success
+
+    async def _send_sendspin_server_url(self) -> None:
+        """Send the Sendspin server URL to the Cast receiver via custom messaging."""
+        # Get the Sendspin server URL from the streams controller
+        server_url = f"http://{self.mass.streams.publish_ip}:8927"
+        # Player name with (Sendspin) suffix for the Sendspin player
+        player_name = f"{self._attr_name} (Sendspin)"
+        # Get sync delay from config (in milliseconds)
+        sync_delay = int(
+            self.mass.config.get_raw_player_config_value(
+                self.player_id, CONF_SENDSPIN_SYNC_DELAY, -350
+            )
+        )
+
+        def send_message() -> None:
+            # Send custom message to receiver with server URL, player ID, name, sync delay
+            self.cc.socket_client.send_app_message(
+                SENDSPIN_CAST_NAMESPACE,
+                {
+                    "serverUrl": server_url,
+                    "playerId": self.sendspin_player_id,
+                    "playerName": player_name,
+                    "syncDelay": sync_delay,
+                },
+            )
+
+        self.logger.debug(
+            "Sending Sendspin config to Cast receiver: url=%s, name=%s, syncDelay=%dms",
+            server_url,
+            player_name,
+            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()
+        while (time.time() - start_time) < timeout:
+            if sendspin_player := self.mass.players.get(self.sendspin_player_id):
+                if sendspin_player.available:
+                    self.logger.debug(
+                        "Sendspin player %s is now available", self.sendspin_player_id
+                    )
+                    return sendspin_player
+            await asyncio.sleep(0.5)
+        self.logger.warning(
+            "Timeout waiting for Sendspin player %s to become available",
+            self.sendspin_player_id,
+        )
+        return None
+
+    async def _play_media_sendspin(self, media: PlayerMedia) -> None:
+        """Handle PLAY MEDIA using the Sendspin protocol via sendspin-over-cast."""
+        self.logger.info(
+            "Starting Sendspin playback on %s (sendspin_player_id=%s)",
+            self.display_name,
+            self.sendspin_player_id,
+        )
+
+        # Check if the sendspin player is already connected (available)
+        if sendspin_player := self.get_linked_sendspin_player(False):
+            # Sendspin player is already connected, just redirect the media
+            self.logger.debug(
+                "Sendspin player already connected (state=%s), redirecting media.",
+                sendspin_player.playback_state,
+            )
+            await self.mass.players.play_media(sendspin_player.player_id, media)
+            return
+
+        # Sendspin player not connected yet - launch app and connect
+        launch_success = await self._launch_sendspin_app()
+        if not launch_success:
+            self.logger.error(
+                "Failed to launch Sendspin Cast App, falling back to standard playback"
+            )
+            await self._fallback_to_standard_playback(media)
+            return
+
+        # Give the app a moment to initialize
+        await asyncio.sleep(1)
+        # Send the Sendspin server URL to the receiver
+        await self._send_sendspin_server_url()
+        # Wait for the Sendspin player to connect
+        sendspin_player = await self._wait_for_sendspin_player()
+        if not sendspin_player:
+            self.logger.error(
+                "Failed to establish Sendspin connection, falling back to standard playback"
+            )
+            await self._fallback_to_standard_playback(media)
+            return
+
+        # Redirect playback to the Sendspin player
+        self.logger.info("Starting playback on Sendspin player %s", sendspin_player.player_id)
+        await self.mass.players.play_media(sendspin_player.player_id, media)
+
+    async def _fallback_to_standard_playback(self, media: PlayerMedia) -> None:
+        """Fall back to standard Chromecast playback when Sendspin fails."""
+        await self._launch_app()
+        queuedata = {
+            "type": "LOAD",
+            "media": self._create_cc_media_item(media),
+        }
+        media_controller = self.cc.media_controller
+        await asyncio.to_thread(media_controller.send_message, data=queuedata, inc_session_id=True)