Various improvements to the Universal Player Group (#746)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 7 Jul 2023 22:54:44 +0000 (00:54 +0200)
committerGitHub <noreply@github.com>
Fri, 7 Jul 2023 22:54:44 +0000 (00:54 +0200)
music_assistant/common/models/config_entries.py
music_assistant/common/models/player.py
music_assistant/server/controllers/players.py
music_assistant/server/models/player_provider.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/ugp/__init__.py

index 91f559980d210d2b76e226cae4540afbebdee5bb..2085aa30d95c69030681319aad7bcb4ac840d716 100644 (file)
@@ -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,
index 51c5515ee24781aa500a514e8c3945e3d9785223..52c201eed7fdcde9110a43fc38b44bfddcfa49a2 100644 (file)
@@ -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."""
index 05202852e72281883b25a477a18eab5d1c1c2613..428105dfb75ebf4f8110da75c9cf2b4fc616ca04 100755 (executable)
@@ -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
index 258a26f6fd97dd4fc077f515d6ef61059418ece4..a6942ef6919e261d64a9ca3788952843df5abcae 100644 (file)
@@ -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
 
index 838b7067fe7e1398bf5cd065a30a0b90a22cf12d..5461d29d55b38987283e584f5f6b9cb06c429073 100644 (file)
@@ -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:
index c2f1b37b01173b58972e396aaf76a5b8d8733b93..7e841f16041675e901dd044740f0390fc1911a00 100644 (file)
@@ -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
index 185fc593fb3dae05e0dfe56d84e48c66e7a171fa..2b1bdd901cf8252959092f17522d9c61e67fce33 100644 (file)
@@ -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