Add volume control to Spotify connect (#2750)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Fri, 5 Dec 2025 16:17:58 +0000 (17:17 +0100)
committerGitHub <noreply@github.com>
Fri, 5 Dec 2025 16:17:58 +0000 (17:17 +0100)
music_assistant/controllers/players/player_controller.py
music_assistant/models/plugin.py
music_assistant/providers/spotify/bin/librespot-linux-aarch64
music_assistant/providers/spotify/bin/librespot-linux-x86_64
music_assistant/providers/spotify/bin/librespot-macos-arm64
music_assistant/providers/spotify_connect/__init__.py

index 83d23c7e0944fb75e3622f6a5ce691592f6fa0a0..aec1624c96e52287f769a0027c0f2ba5fd86916f 100644 (file)
@@ -2250,6 +2250,11 @@ class PlayerController(CoreController):
             )
             await self.cmd_volume_mute(player_id, False)
 
+        # Check if a plugin source is active with a volume callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.on_volume:
+                await plugin_source.on_volume(volume_level)
+
         if player.volume_control == PLAYER_CONTROL_NATIVE:
             # player supports volume command natively: forward to player
             async with self._player_throttlers[player_id]:
index 3a62ab48d34c2190fee78f842b8626fdd7305092..86c56dc3753df2a72eff62ef8fd19c9128e58b7e 100644 (file)
@@ -118,6 +118,14 @@ class PluginSource(PlayerSource):
         repr=False,
     )
 
+    # Callback for volume change command: async def callback(volume: int) -> None
+    on_volume: Callable[[int], Awaitable[None]] | None = field(
+        default=None,
+        compare=False,
+        metadata=field_options(serialize="omit", deserialize=pass_through),
+        repr=False,
+    )
+
     # Callback for when this source is selected: async def callback() -> None
     on_select: Callable[[], Awaitable[None]] | None = field(
         default=None,
index 5c5d7b6aee764c426badd77ca4ce1080acb14714..ca40061b19aa84168f202681776450a1e7db4207 100755 (executable)
Binary files a/music_assistant/providers/spotify/bin/librespot-linux-aarch64 and b/music_assistant/providers/spotify/bin/librespot-linux-aarch64 differ
index 33c65ad0772541c9b90776b323415f72da85549c..7a228b97d00143c182eef312ee59bcc28c2e6dcf 100755 (executable)
Binary files a/music_assistant/providers/spotify/bin/librespot-linux-x86_64 and b/music_assistant/providers/spotify/bin/librespot-linux-x86_64 differ
index 428ebb29b57e0add10050952abbbe8b9ef85e0d5..07f1c3d3f5117a7238346553b08850d5bd2c5699 100755 (executable)
Binary files a/music_assistant/providers/spotify/bin/librespot-macos-arm64 and b/music_assistant/providers/spotify/bin/librespot-macos-arm64 differ
index c7e661930e07a67fdff9b2e590c68069cea7857f..2cc982ad2cf7505f1612c44bc7a4c84b3ab2eda6 100644 (file)
@@ -168,6 +168,7 @@ class SpotifyConnectProvider(PluginProvider):
         self._runner_error_count = 0
         self._spotify_device_id: str | None = None
         self._last_session_connected_time: float = 0
+        self._last_volume_sent_to_spotify: int | None = None
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -253,12 +254,14 @@ class SpotifyConnectProvider(PluginProvider):
             self._source_details.on_next = self._on_next
             self._source_details.on_previous = self._on_previous
             self._source_details.on_seek = self._on_seek
+            self._source_details.on_volume = self._on_volume
         else:
             self._source_details.on_play = None
             self._source_details.on_pause = None
             self._source_details.on_next = None
             self._source_details.on_previous = None
             self._source_details.on_seek = None
+            self._source_details.on_volume = None
 
         # Trigger player update to reflect capability changes
         if self._source_details.in_use_by:
@@ -328,6 +331,30 @@ class SpotifyConnectProvider(PluginProvider):
             self.logger.warning("Failed to send seek command via Spotify Web API: %s", err)
             raise
 
+    async def _on_volume(self, volume: int) -> None:
+        """Handle volume change command via Spotify Web API.
+
+        :param volume: Volume level (0-100) from Music Assistant.
+        """
+        if not self._spotify_provider:
+            raise UnsupportedFeaturedException(
+                "Volume control requires a matching Spotify music provider"
+            )
+
+        # Prevent ping-pong: only send if volume actually changed from what we last sent
+        if self._last_volume_sent_to_spotify == volume:
+            self.logger.debug("Skipping volume update to Spotify - already at %d%%", volume)
+            return
+
+        try:
+            # Bypass throttler for volume changes to ensure responsive UI
+            async with self._spotify_provider.throttler.bypass():
+                await self._spotify_provider._put_data(f"me/player/volume?volume_percent={volume}")
+                self._last_volume_sent_to_spotify = volume
+        except Exception as err:
+            self.logger.warning("Failed to send volume command via Spotify Web API: %s", err)
+            raise
+
     async def _get_spotify_device_id(self) -> str | None:
         """Get the Spotify Connect device ID for this instance.
 
@@ -458,6 +485,11 @@ class SpotifyConnectProvider(PluginProvider):
         await check_output("mkfifo", self.named_pipe)
         await asyncio.sleep(0.1)
         try:
+            # Get initial volume from player, or use 20 as fallback
+            initial_volume = 20
+            _player = self.mass.players.get(self.mass_player_id)
+            if _player and _player.volume_level:
+                initial_volume = _player.volume_level
             args: list[str] = [
                 self._librespot_bin,
                 "--name",
@@ -475,11 +507,11 @@ class SpotifyConnectProvider(PluginProvider):
                 "none",
                 # disable volume control
                 "--mixer",
-                "softvol",
+                "passthrough",
                 "--volume-ctrl",
-                "fixed",
+                "passthrough",
                 "--initial-volume",
-                "100",
+                str(initial_volume),
                 "--enable-volume-normalisation",
                 # forward events to the events script
                 "--onevent",
@@ -633,6 +665,7 @@ class SpotifyConnectProvider(PluginProvider):
             else:
                 # Spotify Connect volume is 0-65535
                 volume = int(int(volume) / 65535 * 100)
+                self._last_volume_sent_to_spotify = volume
                 try:
                     await self.mass.players.cmd_volume_set(self.mass_player_id, volume)
                 except UnsupportedFeaturedException: