From: Marvin Schenkel Date: Fri, 5 Dec 2025 16:17:58 +0000 (+0100) Subject: Add volume control to Spotify connect (#2750) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=7b46e412fe174b6e273954a617c220b9fdfd0d50;p=music-assistant-server.git Add volume control to Spotify connect (#2750) --- diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index 83d23c7e..aec1624c 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -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]: diff --git a/music_assistant/models/plugin.py b/music_assistant/models/plugin.py index 3a62ab48..86c56dc3 100644 --- a/music_assistant/models/plugin.py +++ b/music_assistant/models/plugin.py @@ -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, diff --git a/music_assistant/providers/spotify/bin/librespot-linux-aarch64 b/music_assistant/providers/spotify/bin/librespot-linux-aarch64 index 5c5d7b6a..ca40061b 100755 Binary files a/music_assistant/providers/spotify/bin/librespot-linux-aarch64 and b/music_assistant/providers/spotify/bin/librespot-linux-aarch64 differ diff --git a/music_assistant/providers/spotify/bin/librespot-linux-x86_64 b/music_assistant/providers/spotify/bin/librespot-linux-x86_64 index 33c65ad0..7a228b97 100755 Binary files a/music_assistant/providers/spotify/bin/librespot-linux-x86_64 and b/music_assistant/providers/spotify/bin/librespot-linux-x86_64 differ diff --git a/music_assistant/providers/spotify/bin/librespot-macos-arm64 b/music_assistant/providers/spotify/bin/librespot-macos-arm64 index 428ebb29..07f1c3d3 100755 Binary files a/music_assistant/providers/spotify/bin/librespot-macos-arm64 and b/music_assistant/providers/spotify/bin/librespot-macos-arm64 differ diff --git a/music_assistant/providers/spotify_connect/__init__.py b/music_assistant/providers/spotify_connect/__init__.py index c7e66193..2cc982ad 100644 --- a/music_assistant/providers/spotify_connect/__init__.py +++ b/music_assistant/providers/spotify_connect/__init__.py @@ -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: