or player.name
or player.player_id
)
+ # handle special mute_as_power feature
+ if player.mute_as_power:
+ player.powered = player.powered and not player.volume_muted
# set player state to off if player is not powered
if player.powered and player.state == PlayerState.OFF:
player.state = PlayerState.IDLE
player_prov = self.get_player_provider(group_player.player_id)
if not player_prov:
continue
- player_prov.on_child_state(group_player.player_id, player, changed_values)
+ self.mass.create_task(player_prov.poll_player(group_player.player_id))
def get_player_provider(self, player_id: str) -> PlayerProvider:
"""Return PlayerProvider for given player."""
- powered: bool if player should be powered on or off.
"""
# TODO: Implement PlayerControl
- # TODO: Handle group power
player = self.get(player_id, True)
- if player.powered == powered:
- return
+
+ cur_power = (
+ (player.powered and not player.volume_muted) if player.mute_as_power else player.powered
+ )
+ if cur_power == powered:
+ return # nothing to do
+
# stop player at power off
if (
not powered
and player.state in (PlayerState.PLAYING, PlayerState.PAUSED)
and not player.synced_to
+ and not player.mute_as_power
):
await self.cmd_stop(player_id)
# unsync player at power off
- if not powered:
+ if not powered and not player.mute_as_power:
if player.synced_to is not None:
await self.cmd_unsync(player_id)
for child in self._get_child_players(player):
if not child.synced_to:
continue
await self.cmd_unsync(child.player_id)
+ if player.mute_as_power:
+ # handle mute as power feature
+ await self.cmd_volume_mute(player_id, not powered)
+
if PlayerFeature.POWER not in player.supported_features:
+ # player does not support power, use fake state instead
player.powered = powered
self.update(player_id)
- return
- player_provider = self.get_player_provider(player_id)
- await player_provider.cmd_power(player_id, powered)
+ elif powered or not player.mute_as_power:
+ # regular power command
+ player_provider = self.get_player_provider(player_id)
+ await player_provider.cmd_power(player_id, powered)
+ # handle forward to (active) group player if needed
+ for group_player in self._get_player_groups(player_id):
+ if not group_player.available:
+ continue
+ if not group_player.powered:
+ continue
+ if player_prov := self.get_player_provider(group_player.player_id):
+ await player_prov.on_child_power(group_player.player_id, player, powered)
@api_command("players/cmd/volume_set")
async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
player = self.get(player_id, True)
assert player
if PlayerFeature.VOLUME_MUTE not in player.supported_features:
- LOGGER.warning("Mute command called but player %s does not support muting", player_id)
+ LOGGER.debug("Mute command called but player %s does not support muting", player_id)
player.volume_muted = muted
+ # use volume to process the muting
+ cache_key = f"prev_volume_muting_{player_id}"
+ if muted:
+ await self.mass.cache.set(cache_key, player.volume_level)
+ await self.cmd_volume_set(player_id, 0)
+ else:
+ prev_volume = await self.mass.cache.get(cache_key, default=10)
+ await self.cmd_volume_set(player_id, prev_volume)
self.update(player_id)
return
# TODO: Implement PlayerControl
from __future__ import annotations
import asyncio
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from music_assistant.common.models.config_entries import (
CONF_ENTRY_FLOW_MODE,
CONF_GROUP_MEMBERS = "group_members"
+CONF_MUTE_CHILDS = "mute_childs"
CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO = ConfigEntry.from_dict(
{
}
)
CONF_ENTRY_FORCED_FLOW_MODE = ConfigEntry.from_dict(
- {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True, "value": True}
+ {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": True, "value": True, "hidden": True}
)
# ruff: noqa: ARG002
async def handle_setup(self) -> None:
"""Handle async initialization of the provider."""
self.prev_sync_leaders = {}
+ self.muted_clients = set()
for index in range(1, 100):
conf_key = f"ugp_{index}"
return (
CONF_ENTRY_HIDE_GROUP_MEMBERS,
CONF_ENTRY_GROUPED_POWER_ON,
+ ConfigEntry(
+ key=CONF_MUTE_CHILDS,
+ type=ConfigEntryType.STRING,
+ label="Use muting for power commands",
+ multi_value=True,
+ options=(
+ ConfigValueOption(x.display_name, x.player_id)
+ for x in self._get_active_members(player_id, False, False)
+ ),
+ description="To prevent a restart of the stream, when a child player "
+ "turns on while the group is already playing, you can enable a workaround "
+ "where Music Assistant uses muting to control the group players. \n\n"
+ "This means that while the group player is playing, power actions to these "
+ "child players will be treated as (un)mute commands to prevent the small "
+ "interruption of music when the stream is restarted.",
+ ),
CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO,
CONF_ENTRY_FORCED_FLOW_MODE,
)
group_player.extra_data["optimistic_state"] = PlayerState.PLAYING
async with asyncio.TaskGroup() as tg:
for member in self._get_active_members(
- player_id, only_powered=True, skip_sync_childs=True
+ player_id, only_powered=False, skip_sync_childs=True
):
tg.create_task(self.mass.players.cmd_play(member.player_id))
group_power_on = await self.mass.config.get_player_config_value(
player_id, CONF_GROUPED_POWER_ON
)
+ mute_childs = await self.mass.config.get_player_config_value(player_id, CONF_MUTE_CHILDS)
+ # set mute_as_power feature for group members
+ for child_player_id in mute_childs:
+ if child_player := self.mass.players.get(child_player_id):
+ child_player.mute_as_power = powered
group_player = self.mass.players.get(player_id)
async def set_child_power(child_player: Player) -> None:
for member in self._get_active_members(
player_id, only_powered=not powered, skip_sync_childs=False
):
- if member.powered == member:
- continue
tg.create_task(set_child_power(member))
group_player.powered = powered
group_player.state = PlayerState.IDLE
group_player.current_url = None
- def on_child_state(
- self, player_id: str, child_player: Player, changed_values: dict[str, tuple[Any, Any]]
- ) -> None:
- """Call when the state of a child player updates."""
- self.update_attributes(player_id)
+ async def on_child_power(self, player_id: str, child_player: Player, new_power: bool) -> None:
+ """
+ Call when a power command was executed on one of the child players.
+
+ This is used to handle special actions such as muting as power or (re)syncing.
+ """
group_player = self.mass.players.get(player_id)
- self.mass.players.update(player_id, skip_forward=True)
- if "powered" in changed_values and (prev_power := changed_values["powered"][0]) != (
- new_power := changed_values["powered"][1]
+ mute_childs = self.mass.config.get_raw_player_config_value(player_id, CONF_MUTE_CHILDS, [])
+
+ if not group_player.powered:
+ # guard, this should be caught in the player controller but just in case...
+ return
+
+ powered_childs = self._get_active_members(player_id, True, False)
+ if not new_power and child_player in powered_childs:
+ powered_childs.remove(child_player)
+
+ # if the last player of a group turned off, turn off the group
+ if len(powered_childs) == 0:
+ self.mass.create_task(self.cmd_power, player_id, False)
+ return False
+
+ # if a child player turned ON while the group player is already playing
+ # we need to resync/resume
+ if (
+ group_player.powered
+ and new_power
+ and group_player.extra_data["optimistic_state"] == PlayerState.PLAYING
+ and child_player.state != PlayerState.PLAYING
):
- powered_players = self._get_active_members(player_id, True, False)
- if group_player.powered and prev_power is True and len(powered_players) == 0:
- # the last player of a group turned off, turn off the group
- self.mass.create_task(self.cmd_power, player_id, False)
- # ruff: noqa: SIM114
- elif (
- new_power is True
- and group_player.extra_data["optimistic_state"] == PlayerState.PLAYING
- ):
- # a child player turned ON while the group player is already playing
- # we need to resync/resume
- if group_player.state == PlayerState.PLAYING and (
- sync_leader := next(
- (
- x
- for x in child_player.can_sync_with
- if x in self.prev_sync_leaders[player_id]
- ),
- None,
- )
- ):
- # prevent resume when player platform supports sync
- # and one of its players is already playing
- self.mass.create_task(
- self.mass.players.cmd_sync, child_player.player_id, sync_leader
- )
- else:
- self.mass.create_task(self.mass.player_queues.resume, player_id)
- elif (
- not child_player.powered
- and group_player.extra_data["optimistic_state"] == PlayerState.PLAYING
- and child_player.player_id in self.prev_sync_leaders[player_id]
+ if group_player.state == PlayerState.PLAYING and (
+ sync_leader := next(
+ (
+ x
+ for x in child_player.can_sync_with
+ if x in self.prev_sync_leaders[player_id]
+ ),
+ None,
+ )
):
- # a sync master player turned OFF while the group player
- # should still be playing - we need to resync/resume
+ # prevent resume when player platform supports sync
+ # and one of its players is already playing
+ self.mass.create_task(
+ self.mass.players.cmd_sync, child_player.player_id, sync_leader
+ )
+ else:
self.mass.create_task(self.mass.player_queues.resume, player_id)
+ elif (
+ not new_power
+ and group_player.extra_data["optimistic_state"] == PlayerState.PLAYING
+ and child_player.player_id in self.prev_sync_leaders[player_id]
+ and child_player.player_id not in mute_childs
+ ):
+ # a sync master player turned OFF while the group player
+ # should still be playing - we need to resync/resume
+ self.mass.create_task(self.mass.player_queues.resume, player_id)
def _get_active_members(
- self, player_id: str, only_powered: bool = False, skip_sync_childs: bool = True
+ self,
+ player_id: str,
+ only_powered: bool = False,
+ skip_sync_childs: bool = True,
) -> list[Player]:
"""Get (child) players attached to a grouped player."""
child_players: list[Player] = []
conf_members: list[str] = self.config.get_value(player_id)
+ mute_childs: list[str] = self.mass.config.get_raw_player_config_value(
+ player_id, CONF_MUTE_CHILDS, []
+ )
ignore_ids = set()
for child_id in conf_members:
if child_player := self.mass.players.get(child_id, False):
- if not (not only_powered or child_player.powered):
+ # work out power state
+ player_powered = True if child_id in mute_childs else child_player.powered
+ if not (not only_powered or player_powered):
continue
if child_player.synced_to and skip_sync_childs:
continue