From: scyto Date: Fri, 6 Feb 2026 22:38:01 +0000 (-0800) Subject: Add group volume mute support (#3034) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=759f45d4826df713de87663c848b84e7b79c94cd;p=music-assistant-server.git Add group volume mute support (#3034) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index cba20cee..c54c27b6 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -896,6 +896,7 @@ ATTR_GROUP_MEMBERS: Final[str] = "group_members" ATTR_ELAPSED_TIME: Final[str] = "elapsed_time" ATTR_ENABLED: Final[str] = "enabled" ATTR_AVAILABLE: Final[str] = "available" +ATTR_MUTE_LOCK: Final[str] = "mute_lock" # Album type detection patterns LIVE_INDICATORS = [ diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index fa3137e3..5ab1ba99 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -60,6 +60,7 @@ from music_assistant.constants import ( ATTR_FAKE_VOLUME, ATTR_GROUP_MEMBERS, ATTR_LAST_POLL, + ATTR_MUTE_LOCK, ATTR_PREVIOUS_VOLUME, CONF_AUTO_PLAY, CONF_ENTRY_ANNOUNCE_VOLUME, @@ -832,6 +833,25 @@ class PlayerController(CoreController): new_volume = max(0, cur_volume - step_size) await self.cmd_group_volume(player_id, new_volume) + @api_command("players/cmd/group_volume_mute") + @handle_player_command + async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None: + """Send VOLUME_MUTE command to all players in a group. + + - player_id: player_id of the group player or sync leader. + - muted: bool if group should be muted. + """ + player = self.get(player_id, True) + assert player is not None # for type checker + if player.type == PlayerType.GROUP or player.group_members: + # dedicated group player or sync leader + coros = [] + for child_player in self.iter_group_members( + player, only_powered=True, exclude_self=False + ): + coros.append(self.cmd_volume_mute(child_player.player_id, muted)) + await asyncio.gather(*coros) + @api_command("players/cmd/volume_mute") @handle_player_command async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: @@ -842,6 +862,15 @@ class PlayerController(CoreController): """ player = self.get(player_id, True) assert player + + # Set/clear mute lock for players in a group + # This prevents auto-unmute when group volume changes + is_in_group = bool(player.synced_to or player.group_members) + if muted and is_in_group: + player.extra_data[ATTR_MUTE_LOCK] = True + elif not muted: + player.extra_data.pop(ATTR_MUTE_LOCK, None) + if player.mute_control == PLAYER_CONTROL_NONE: raise UnsupportedFeaturedException( f"Player {player.display_name} does not support muting" @@ -1705,6 +1734,7 @@ class PlayerController(CoreController): new_child_volume = max(0, new_child_volume) new_child_volume = min(100, new_child_volume) # Use private method to skip permission check - already validated on group + # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume)) await asyncio.gather(*coros) @@ -2398,11 +2428,15 @@ class PlayerController(CoreController): f"Player {player.display_name} does not support volume control" ) + # Check if player has mute lock (set when individually muted in a group) + # If locked, don't auto-unmute when volume changes + has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False) if ( - player.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE) + not has_mute_lock + and player.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE) and player.volume_muted ): - # if player is muted, we unmute it first + # if player is muted and not locked, we unmute it first # skip this for fake mute since it uses volume to simulate mute self.logger.debug( "Unmuting player %s before setting volume",