From: Maxim Raznatovski Date: Fri, 5 Dec 2025 16:16:26 +0000 (+0100) Subject: Add Experimental Sendspin over Cast Support (#2737) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=aa3fc4467e114ce49436637a548605cecf6ff719;p=music-assistant-server.git Add Experimental Sendspin over Cast Support (#2737) --- diff --git a/music_assistant/providers/chromecast/constants.py b/music_assistant/providers/chromecast/constants.py index af26e017..07bb79d2 100644 --- a/music_assistant/providers/chromecast/constants.py +++ b/music_assistant/providers/chromecast/constants.py @@ -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, diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 309d8f29..fbb4fe3b 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -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)