From: Marcel van der Veldt Date: Fri, 7 Jul 2023 22:54:44 +0000 (+0200) Subject: Various improvements to the Universal Player Group (#746) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f970e3e09e71a6a5b12f975d49703587252a0be1;p=music-assistant-server.git Various improvements to the Universal Player Group (#746) --- diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 91f55998..2085aa30 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -283,13 +283,13 @@ CONF_ENTRY_LOG_LEVEL = ConfigEntry( key=CONF_LOG_LEVEL, type=ConfigEntryType.STRING, label="Log level", - options=[ + options=( ConfigValueOption("global", "GLOBAL"), ConfigValueOption("info", "INFO"), ConfigValueOption("warning", "WARNING"), ConfigValueOption("error", "ERROR"), ConfigValueOption("debug", "DEBUG"), - ], + ), default_value="GLOBAL", description="Set the log verbosity for this provider", advanced=True, diff --git a/music_assistant/common/models/player.py b/music_assistant/common/models/player.py index 51c5515e..52c201ee 100644 --- a/music_assistant/common/models/player.py +++ b/music_assistant/common/models/player.py @@ -98,6 +98,9 @@ class Player(DataClassDictMixin): # and pass along freely extra_data: dict[str, Any] = field(default_factory=dict) + # mute_as_power: special feature from the universal group + mute_as_power: bool = False + @property def corrected_elapsed_time(self) -> float: """Return the corrected/realtime elapsed time.""" diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 05202852..428105df 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -190,6 +190,9 @@ class PlayerController(CoreController): 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 @@ -243,7 +246,7 @@ class PlayerController(CoreController): 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.""" @@ -324,31 +327,50 @@ class PlayerController(CoreController): - 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: @@ -420,8 +442,16 @@ class PlayerController(CoreController): 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 diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 258a26f6..a6942ef6 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from music_assistant.common.models.player import Player from music_assistant.common.models.queue_item import QueueItem @@ -153,12 +153,12 @@ class PlayerProvider(Provider): If the player does not need any polling, simply do not override this method. """ - 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.""" - # default implementation: simply update the state of the group player - self.mass.players.update(player_id, skip_forward=True) + 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. + """ # DO NOT OVERRIDE BELOW diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index 838b7067..5461d29d 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -242,10 +242,11 @@ class ChromecastProvider(PlayerProvider): async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player.""" castplayer = self.castplayers[player_id] - # handle player that is hidden by active group player, use mute as power - if castplayer.active_group: - await self.cmd_volume_mute(player_id, not powered) - return + # set mute_as_power feature for group members + if castplayer.player.type == PlayerType.GROUP: + for child_player_id in castplayer.player.group_childs: + if child_player := self.mass.players.get(child_player_id): + child_player.mute_as_power = powered if powered: await self._launch_app(castplayer) else: diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index c2f1b37b..7e841f16 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -207,7 +207,7 @@ class SlimprotoProvider(PlayerProvider): self.mass.streams.publish_ip, self.port, self._cli.cli_port if enable_telnet else None, - self.mass.streams.publish_ip if enable_json else None, + self.mass.streams.publish_port if enable_json else None, "Music Assistant", self.mass.server_id, ) @@ -485,11 +485,6 @@ class SlimprotoProvider(PlayerProvider): f"{CACHE_KEY_PREV_STATE}.{player_id}", (client.powered, volume_level) ) - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME MUTE command to given player.""" - if client := self._socket_clients.get(player_id): - await client.mute(muted) - async def cmd_sync(self, player_id: str, target_player: str) -> None: """Handle SYNC command for given player.""" child_player = self.mass.players.get(player_id) @@ -579,7 +574,6 @@ class SlimprotoProvider(PlayerProvider): PlayerFeature.ACCURATE_TIME, PlayerFeature.POWER, PlayerFeature.SYNC, - PlayerFeature.VOLUME_MUTE, PlayerFeature.VOLUME_SET, ), max_sample_rate=int(client.max_sample_rate), @@ -596,8 +590,6 @@ class SlimprotoProvider(PlayerProvider): player.powered = client.powered player.state = STATE_MAP[client.state] player.volume_level = client.volume_level - # player.volume_muted = client.muted - player.volume_muted = client.powered and client.muted # set all existing player ids in `can_sync_with` field player.can_sync_with = tuple( x.player_id for x in self._socket_clients.values() if x.player_id != player_id diff --git a/music_assistant/server/providers/ugp/__init__.py b/music_assistant/server/providers/ugp/__init__.py index 185fc593..2b1bdd90 100644 --- a/music_assistant/server/providers/ugp/__init__.py +++ b/music_assistant/server/providers/ugp/__init__.py @@ -7,7 +7,7 @@ allowing the user to create player groups from all players known in the system. 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, @@ -38,6 +38,7 @@ if TYPE_CHECKING: CONF_GROUP_MEMBERS = "group_members" +CONF_MUTE_CHILDS = "mute_childs" CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO = ConfigEntry.from_dict( { @@ -48,7 +49,7 @@ 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 @@ -113,6 +114,7 @@ class UniversalGroupProvider(PlayerProvider): 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}" @@ -155,6 +157,22 @@ class UniversalGroupProvider(PlayerProvider): 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, ) @@ -178,7 +196,7 @@ class UniversalGroupProvider(PlayerProvider): 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)) @@ -249,6 +267,11 @@ class UniversalGroupProvider(PlayerProvider): 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: @@ -262,8 +285,6 @@ class UniversalGroupProvider(PlayerProvider): 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 @@ -311,63 +332,81 @@ class UniversalGroupProvider(PlayerProvider): 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