Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions music_assistant/providers/airplay/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion tests/providers/airplay/test_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Loading