diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 2332705bf1..47d35ea2c5 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, cast from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType +from music_assistant_models.constants import PLAYER_CONTROL_NATIVE from music_assistant_models.enums import ( ConfigEntryType, IdentifierType, @@ -923,14 +924,18 @@ def sync_volume_level(self) -> None: AirPlay players only report their volume level when we are actually streaming to them and we remember the last used/reported volume level in the player config by default - but if we have a parent player, that may know better about the current volume level, - so we try to sync from that parent player if possible + but if we have a parent player without native volume control, that parent may know better + about the current volume level, so we try to sync from that parent player if possible. """ if ( self.protocol_parent_id and (parent_player := self.mass.players.get_player(self.protocol_parent_id)) and parent_player.state.volume_level is not None ): + # Native parent volume is the device/amplifier volume, while AirPlay volume is + # stream/protocol volume. Some receivers map those scales differently. + if parent_player.volume_control == PLAYER_CONTROL_NATIVE: + return if self._attr_volume_level == parent_player.state.volume_level: return self._attr_volume_level = parent_player.state.volume_level diff --git a/tests/providers/airplay/test_player.py b/tests/providers/airplay/test_player.py index 5acdfe9371..e8945817ca 100644 --- a/tests/providers/airplay/test_player.py +++ b/tests/providers/airplay/test_player.py @@ -3,8 +3,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from music_assistant_models.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE -from music_assistant.providers.airplay.constants import StreamingProtocol +from music_assistant.providers.airplay.constants import CONF_STORED_VOLUME, StreamingProtocol from music_assistant.providers.airplay.player import AirPlayPlayer @@ -200,3 +201,43 @@ async def test_volume_mute_no_stream(airplay_player: AirPlayPlayer) -> None: assert airplay_player._attr_volume_muted is True mock_update.assert_called_once() + + +def test_sync_volume_level_skips_parent_with_native_volume_control( + airplay_player: AirPlayPlayer, +) -> None: + """Do not copy native parent volume into the AirPlay stream volume.""" + parent = MagicMock() + parent.state.volume_level = 36 + parent.volume_control = PLAYER_CONTROL_NATIVE + airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined] + airplay_player.set_protocol_parent_id("native_parent") + airplay_player._attr_volume_level = 48 + + with patch.object(AirPlayPlayer, "update_state") as mock_update: + airplay_player.sync_volume_level() + + assert airplay_player._attr_volume_level == 48 + airplay_player.mass.config.set_raw_player_config_value.assert_not_called() # type: ignore[attr-defined] + mock_update.assert_not_called() + + +def test_sync_volume_level_uses_parent_without_native_volume_control( + airplay_player: AirPlayPlayer, +) -> None: + """Keep syncing parent volume for parents that delegate volume to a protocol/control.""" + parent = MagicMock() + parent.state.volume_level = 36 + parent.volume_control = PLAYER_CONTROL_NONE + airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined] + airplay_player.set_protocol_parent_id("protocol_parent") + airplay_player._attr_volume_level = 48 + + with patch.object(AirPlayPlayer, "update_state") as mock_update: + airplay_player.sync_volume_level() + + assert airplay_player._attr_volume_level == 36 + airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( # type: ignore[attr-defined] + airplay_player.player_id, CONF_STORED_VOLUME, 36 + ) + mock_update.assert_called_once()