Various follow up bugfixes regarding the players refactor (#2466)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 2 Oct 2025 14:32:51 +0000 (16:32 +0200)
committerGitHub <noreply@github.com>
Thu, 2 Oct 2025 14:32:51 +0000 (16:32 +0200)
21 files changed:
music_assistant/controllers/players/player_controller.py
music_assistant/controllers/players/sync_groups.py
music_assistant/mass.py
music_assistant/models/core_controller.py
music_assistant/models/player.py
music_assistant/providers/_demo_player_provider/player.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/raop.py
music_assistant/providers/alexa/__init__.py
music_assistant/providers/bluesound/player.py
music_assistant/providers/builtin_player/manifest.json
music_assistant/providers/builtin_player/player.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/fully_kiosk/player.py
music_assistant/providers/hass_players/player.py
music_assistant/providers/resonate/manifest.json
music_assistant/providers/resonate/player.py
music_assistant/providers/roku_media_assistant/player.py
music_assistant/providers/sonos_s1/player.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/universal_group/manifest.json

index 0c260c2c575bb8460de9d4d156983adbd2c8ca99..ded831a549c60853f50427c79763dc0504bfb52e 100644 (file)
@@ -364,13 +364,13 @@ class PlayerController(CoreController):
                 "Ignore PLAY request to player %s: player is already playing", player.display_name
             )
             return
-        # Redirect to queue controller if it is active
-        if active_queue := self.get_active_queue(player):
-            await self.mass.player_queues.play(active_queue.queue_id)
-            return
-        # handle command on player directly
-        async with self._player_throttlers[player.player_id]:
-            await player.play()
+        if player.playback_state == PlaybackState.PAUSED:
+            # handle command on player directly
+            async with self._player_throttlers[player.player_id]:
+                await player.play()
+        else:
+            # try to resume the player
+            await self.cmd_resume(player.player_id)
 
     @api_command("players/cmd/pause")
     @handle_player_command
@@ -407,6 +407,46 @@ class PlayerController(CoreController):
         else:
             await self.cmd_play(player.player_id)
 
+    @api_command("players/cmd/resume")
+    async def cmd_resume(
+        self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
+    ) -> None:
+        """
+        Send RESUME command to given player.
+
+        Resume (or restart) playback on the player.
+        """
+        player = self._get_player_with_redirect(player_id)
+        source = source or player.active_source
+        media = media or player.current_media
+        # power on the player if needed
+        if not player.powered and player.power_control != PLAYER_CONTROL_NONE:
+            await self.cmd_power(player.player_id, True)
+        # Redirect to queue controller if it is active
+        if active_queue := self.mass.player_queues.get(source or player_id):
+            await self.mass.player_queues.resume(active_queue.queue_id)
+            return
+        # try to handle command on player directly
+        # TODO: check if player has an active source with native resume support
+        active_source = next((x for x in player.source_list if x.id == source), None)
+        if (
+            player.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
+            and active_source
+            and active_source.can_play_pause
+        ):
+            # player has some other source active and native resume support
+            await player.play()
+            return
+        if active_source and not active_source.passive:
+            await player.select_source(active_source.id)
+            return
+        if media:
+            # try to re-play the current media item
+            await player.play_media(media)
+            return
+        # fallback: just send play command - which will fail if nothing can be played
+        await player.play()
+
     @api_command("players/cmd/seek")
     async def cmd_seek(self, player_id: str, position: int) -> None:
         """Handle SEEK command for given player.
@@ -472,7 +512,7 @@ class PlayerController(CoreController):
 
     @api_command("players/cmd/power")
     @handle_player_command
-    async def cmd_power(self, player_id: str, powered: bool, skip_update: bool = False) -> None:
+    async def cmd_power(self, player_id: str, powered: bool) -> None:
         """Send POWER command to given player.
 
         - player_id: player_id of the player to handle the command.
@@ -529,6 +569,7 @@ class PlayerController(CoreController):
             # user wants to use fake power control - so we (optimistically) update the state
             # and store the state in the cache
             player.extra_data[ATTR_FAKE_POWER] = powered
+            player.update_state()  # trigger update of the player state
             await self.mass.cache.set(
                 key=player_id,
                 data=powered,
@@ -551,15 +592,8 @@ class PlayerController(CoreController):
                 assert player_control.power_off is not None  # for type checking
                 await player_control.power_off()
 
-        # always optimistically set the power state to update the UI
-        # as fast as possible and prevent race conditions
-        player_state.powered = powered
-        # reset active source on power off
-        if not powered:
-            player_state.active_source = None
-
-        if not skip_update:
-            player.update_state()
+        # always trigger a state update to update the UI
+        player.update_state()
 
         # handle 'auto play on power on' feature
         if (
@@ -630,7 +664,7 @@ class PlayerController(CoreController):
         """
         if not (player := self.get(player_id)):
             return
-        current_volume = player.volume_state or 0
+        current_volume = player.volume_level or 0
         if current_volume < 5 or current_volume > 95:
             step_size = 1
         elif current_volume < 20 or current_volume > 80:
@@ -649,7 +683,7 @@ class PlayerController(CoreController):
         """
         if not (player := self.get(player_id)):
             return
-        current_volume = player.volume_state or 0
+        current_volume = player.volume_level or 0
         if current_volume < 5 or current_volume > 95:
             step_size = 1
         elif current_volume < 20 or current_volume > 80:
@@ -750,14 +784,16 @@ class PlayerController(CoreController):
                 player.display_name,
             )
             if muted:
-                player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_state
+                player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_level
                 player.extra_data[ATTR_FAKE_MUTE] = True
                 await self.cmd_volume_set(player_id, 0)
+                player.update_state()
             else:
                 player._attr_volume_muted = False
                 prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
                 player.extra_data[ATTR_FAKE_MUTE] = False
                 await self.cmd_volume_set(player_id, prev_volume)
+                player.update_state()
         else:
             # handle external player control
             player_control = self._controls.get(player.mute_control)
@@ -1053,7 +1089,7 @@ class PlayerController(CoreController):
 
             # power on the player if needed
             if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE:
-                await self.cmd_power(child_player.player_id, True, skip_update=True)
+                await self.cmd_power(child_player.player_id, True)
             # if we reach here, all checks passed
             final_player_ids_to_add.append(child_player_id)
 
@@ -1817,12 +1853,11 @@ class PlayerController(CoreController):
         This default implementation will only be used if the player
         (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
         """
-        prev_power = player.powered
         prev_state = player.playback_state
+        prev_power = player.powered or prev_state != PlaybackState.IDLE
         prev_synced_to = player.synced_to
         prev_group = self.get(player.active_group) if player.active_group else None
         prev_source = player.active_source
-        prev_queue = self.get_active_queue(player)
         prev_media = player.current_media
         prev_media_name = prev_media.title or prev_media.uri if prev_media else None
         if prev_synced_to:
@@ -1931,14 +1966,23 @@ class PlayerController(CoreController):
             for volume_player_id, prev_volume in prev_volumes.items():
                 tg.create_task(self.cmd_volume_set(volume_player_id, prev_volume))
         await asyncio.sleep(0.2)
-        player.current_media = prev_media
-        player.active_source = prev_source
         # either power off the player or resume playing
-        if not prev_power and player.power_control != PLAYER_CONTROL_NONE:
-            await self.cmd_power(player.player_id, False)
+        if not prev_power:
+            if player.power_control != PLAYER_CONTROL_NONE:
+                self.logger.debug(
+                    "Announcement to player %s - turning player off again...", player.display_name
+                )
+                await self.cmd_power(player.player_id, False)
+            # nothing to do anymore, player was not previously powered
+            # and does not support power control
             return
         elif prev_synced_to:
-            await self.cmd_group(player.player_id, prev_synced_to)
+            self.logger.debug(
+                "Announcement to player %s - syncing back to %s...",
+                player.display_name,
+                prev_synced_to,
+            )
+            await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
         elif prev_group:
             if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
                 self.logger.debug(
@@ -1956,18 +2000,9 @@ class PlayerController(CoreController):
                     prev_group.display_name,
                 )
                 await self.cmd_play(prev_group.player_id)
-        elif prev_queue and prev_state == PlaybackState.PLAYING:
-            await self.mass.player_queues.resume(prev_queue.queue_id, True)
-            await self.wait_for_state(player, PlaybackState.PLAYING, 5)
         elif prev_state == PlaybackState.PLAYING:
-            # player was playing something else - try to resume that here
-            for source in player.source_list_state:
-                if source.id == prev_source and not source.passive:
-                    await player.select_source(source.id)
-                    break
-            else:
-                # no source found, try to resume the previous media
-                await self.cmd_play(player.player_id)
+            # player was playing something before the announcement - try to resume that here
+            await self.cmd_resume(player.player_id, prev_source, prev_media)
 
     async def _poll_players(self) -> None:
         """Background task that polls players for updates."""
index 0d95ca9e1a4d54a9b8d6613efbc020662774991b..4aad160e7689be74ff57dc698baff11c6322a4c9 100644 (file)
@@ -53,7 +53,6 @@ SUPPORT_DYNAMIC_LEADER = {
     # and the music keeps playing uninterrupted.
     "airplay",
     "squeezelite",
-    "resonate",
     # TODO: Get this working with Sonos as well (need to handle range requests)
 }
 
@@ -126,7 +125,7 @@ class SyncGroupPlayer(GroupPlayer):
     @property
     def playback_state(self) -> PlaybackState:
         """Return the current playback state of the player."""
-        if self.power_state:
+        if self.powered:
             return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE
         else:
             return PlaybackState.IDLE
@@ -154,20 +153,20 @@ class SyncGroupPlayer(GroupPlayer):
         return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None
 
     @property
-    def current_media(self) -> PlayerMedia | None:
+    def _current_media(self) -> PlayerMedia | None:
         """Return the current media item (if any) loaded in the player."""
-        return self.sync_leader.current_media if self.sync_leader else self._attr_current_media
+        return self.sync_leader._current_media if self.sync_leader else self._attr_current_media
 
     @property
-    def active_source(self) -> str | None:
+    def _active_source(self) -> str | None:
         """Return the active source id (if any) of the player."""
-        return self._attr_active_source
+        return self.sync_leader._active_source if self.sync_leader else self._attr_active_source
 
     @property
-    def source_list(self) -> list[PlayerSource]:
+    def _source_list(self) -> list[PlayerSource]:
         """Return list of available (native) sources for this player."""
         if self.sync_leader:
-            return self.sync_leader.source_list
+            return self.sync_leader._source_list
         return []
 
     @property
@@ -259,6 +258,8 @@ class SyncGroupPlayer(GroupPlayer):
         # always stop at power off
         if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
             await self.stop()
+            self._attr_current_media = None
+            self._attr_active_source = None
 
         # optimistically set the group state
 
@@ -327,6 +328,19 @@ class SyncGroupPlayer(GroupPlayer):
         if sync_leader := self.sync_leader:
             await sync_leader.enqueue_next_media(media)
 
+    async def select_source(self, source: str) -> None:
+        """
+        Handle SELECT SOURCE command on the player.
+
+        Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
+
+        :param source: The source(id) to select, as defined in the source_list.
+        """
+        if sync_leader := self.sync_leader:
+            await sync_leader.select_source(source)
+            self._attr_active_source = source
+            self.update_state()
+
     async def set_members(
         self,
         player_ids_to_add: list[str] | None = None,
@@ -399,6 +413,7 @@ class SyncGroupPlayer(GroupPlayer):
         ]
         self.update_state()
         members_to_sync: list[str] = []
+        members_to_remove: list[str] = []
         for member in self.mass.players.iter_group_members(self, active_only=False):
             # Handle collisions before attempting to sync
             await self._handle_member_collisions(member)
@@ -416,8 +431,13 @@ class SyncGroupPlayer(GroupPlayer):
                 # already synced
                 continue
             members_to_sync.append(member.player_id)
-        if members_to_sync:
-            await self.sync_leader.set_members(members_to_sync)
+        for former_members in self.sync_leader.group_members:
+            if (
+                former_members not in members_to_sync
+            ) and former_members != self.sync_leader.player_id:
+                members_to_remove.append(former_members)
+        if members_to_sync or members_to_remove:
+            await self.sync_leader.set_members(members_to_sync, members_to_remove)
 
     async def _dissolve_syncgroup(self) -> None:
         """Dissolve the current syncgroup by ungrouping all members and restoring leader queue."""
@@ -465,8 +485,11 @@ class SyncGroupPlayer(GroupPlayer):
             await self._form_syncgroup()
 
             # Restart playback if requested and we have media to play
-            if was_playing and self.current_media is not None:
-                await new_leader.play_media(self.current_media)
+            if was_playing:
+                await self.mass.players.cmd_resume(self.player_id)
+        else:
+            # We have no leader anymore, send update since we stopped playback
+            self.update_state()
 
     def _select_sync_leader(self) -> Player | None:
         """Select the active sync leader player for a syncgroup."""
index 74dcfb57a67d732e5fa4f80eb0f79218d797e2e9..3cec14240b8f7153eca71cfc1cb0692ca4fcd377 100644 (file)
@@ -643,6 +643,9 @@ class MusicAssistant:
         """Load providers from config."""
         # create default config for any 'builtin' providers (e.g. URL provider)
         for prov_manifest in self._provider_manifests.values():
+            if prov_manifest.type == ProviderType.CORE:
+                # core controllers are not real providers
+                continue
             if not prov_manifest.builtin:
                 continue
             await self.config.create_builtin_provider_config(prov_manifest.domain)
index efff1497a8eeef0e54ccdadab080d5f2978e080e..be91dd25ffeaef85c45c9520219aa5275b64995b 100644 (file)
@@ -5,7 +5,7 @@ from __future__ import annotations
 import logging
 from typing import TYPE_CHECKING
 
-from music_assistant_models.enums import ProviderType
+from music_assistant_models.enums import ProviderStage, ProviderType
 from music_assistant_models.provider import ProviderManifest
 
 from music_assistant.constants import CONF_LOG_LEVEL, MASS_LOGGER_NAME
@@ -32,7 +32,10 @@ class CoreController:
             name=f"{self.domain.title()} Core controller",
             description=f"{self.domain.title()} Core controller",
             codeowners=["@music-assistant"],
+            stage=ProviderStage.STABLE,
             icon="puzzle-outline",
+            builtin=True,
+            allow_disable=False,
         )
 
     async def get_config_entries(
index 1c5b404a28aca3bedb43515711d643cb69c9dd8a..aea3a41033422df4b8dc52950479a03cff0e421b 100644 (file)
@@ -202,41 +202,11 @@ class Player(ABC):
         """Return the supported features of the player."""
         return self._attr_supported_features
 
-    @property
-    def powered(self) -> bool | None:
-        """
-        Return if the player is powered on.
-
-        If the player does not support PlayerFeature.POWER,
-        or the state is (currently) unknown, this property may return None.
-        """
-        return self._attr_powered
-
     @property
     def playback_state(self) -> PlaybackState:
         """Return the current playback state of the player."""
         return self._attr_playback_state
 
-    @property
-    def volume_level(self) -> int | None:
-        """
-        Return the current volume level (0..100) of the player.
-
-        If the player does not support PlayerFeature.VOLUME_SET,
-        or the state is (currently) unknown, this property may return None.
-        """
-        return self._attr_volume_level
-
-    @property
-    def volume_muted(self) -> bool | None:
-        """
-        Return the current mute state of the player.
-
-        If the player does not support PlayerFeature.VOLUME_MUTE,
-        or the state is (currently) unknown, this property may return None.
-        """
-        return self._attr_volume_muted
-
     @cached_property
     def flow_mode(self) -> bool:
         """
@@ -310,26 +280,6 @@ class Player(ABC):
         """
         return self._attr_can_group_with
 
-    @property
-    def active_source(self) -> str | None:
-        """
-        Return the (id of) the active source of the player.
-
-        Set to None if the player is not currently playing a source or
-        the player_id if the player is currently playing a MA queue.
-        """
-        return self._attr_active_source
-
-    @property
-    def source_list(self) -> list[PlayerSource]:
-        """Return list of available (native) sources for this player."""
-        return self._attr_source_list
-
-    @property
-    def current_media(self) -> PlayerMedia | None:
-        """Return the current media being played by the player."""
-        return self._attr_current_media
-
     @property
     def needs_poll(self) -> bool:
         """Return if the player needs to be polled for state updates."""
@@ -360,6 +310,90 @@ class Player(ABC):
         """Return if the player should be enabled by default."""
         return self._attr_enabled_by_default
 
+    @property
+    def _powered(self) -> bool | None:
+        """
+        Return if the player is powered on.
+
+        If the player does not support PlayerFeature.POWER,
+        or the state is (currently) unknown, this property may return None.
+
+        Note that this is NOT the final power state of the player,
+        as it may be overridden by a playercontrol.
+        Hence it's marked as a private property.
+        The final power state can be retrieved by using the 'powered' property.
+        """
+        return self._attr_powered
+
+    @property
+    def _volume_level(self) -> int | None:
+        """
+        Return the current volume level (0..100) of the player.
+
+        If the player does not support PlayerFeature.VOLUME_SET,
+        or the state is (currently) unknown, this property may return None.
+
+        Note that this is NOT the final volume level state of the player,
+        as it may be overridden by a playercontrol.
+        Hence it's marked as a private property.
+        The final volume level state can be retrieved by using the 'volume_level' property.
+        """
+        return self._attr_volume_level
+
+    @property
+    def _volume_muted(self) -> bool | None:
+        """
+        Return the current mute state of the player.
+
+        If the player does not support PlayerFeature.VOLUME_MUTE,
+        or the state is (currently) unknown, this property may return None.
+
+        Note that this is NOT the final muted state of the player,
+        as it may be overridden by a playercontrol.
+        Hence it's marked as a private property.
+        The final muted state can be retrieved by using the 'volume_muted' property.
+        """
+        return self._attr_volume_muted
+
+    @property
+    def _active_source(self) -> str | None:
+        """
+        Return the (id of) the active source of the player.
+
+        Set to None if the player is not currently playing a source or
+        the player_id if the player is currently playing a MA queue.
+
+        Note that this is NOT the final active source of the player,
+        as it may be overridden by a active group/sync membership.
+        Hence it's marked as a private property.
+        The final active source can be retrieved by using the 'active_source' property.
+        """
+        return self._attr_active_source
+
+    @property
+    def _current_media(self) -> PlayerMedia | None:
+        """
+        Return the current media being played by the player.
+
+        Note that this is NOT the final current media of the player,
+        as it may be overridden by a active group/sync membership.
+        Hence it's marked as a private property.
+        The final current media can be retrieved by using the 'current_media' property.
+        """
+        return self._attr_current_media
+
+    @property
+    def _source_list(self) -> list[PlayerSource]:
+        """
+        Return list of available (native) sources for this player.
+
+        Note that this is NOT the final source list of the player,
+        as we inject the MA queue source if the player is currently playing a MA queue.
+        Hence it's marked as a private property.
+        The final source list can be retrieved by using the 'source_list' property.
+        """
+        return self._attr_source_list
+
     async def power(self, powered: bool) -> None:
         """
         Handle POWER command on the player.
@@ -721,7 +755,7 @@ class Player(ABC):
 
     @cached_property
     @final
-    def power_state(self) -> bool | None:
+    def powered(self) -> bool | None:
         """
         Return the FINAL power state of the player.
 
@@ -732,7 +766,7 @@ class Player(ABC):
         if power_control == PLAYER_CONTROL_FAKE:
             return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
         if power_control == PLAYER_CONTROL_NATIVE:
-            return self.powered
+            return self._powered
         if power_control == PLAYER_CONTROL_NONE:
             return None
         if control := self.mass.players.get_player_control(power_control):
@@ -741,7 +775,7 @@ class Player(ABC):
 
     @cached_property
     @final
-    def volume_state(self) -> int | None:
+    def volume_level(self) -> int | None:
         """
         Return the FINAL volume level of the player.
 
@@ -752,7 +786,7 @@ class Player(ABC):
         if volume_control == PLAYER_CONTROL_FAKE:
             return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
         if volume_control == PLAYER_CONTROL_NATIVE:
-            return self.volume_level
+            return self._volume_level
         if volume_control == PLAYER_CONTROL_NONE:
             return None
         if control := self.mass.players.get_player_control(volume_control):
@@ -761,7 +795,7 @@ class Player(ABC):
 
     @cached_property
     @final
-    def volume_muted_state(self) -> bool | None:
+    def volume_muted(self) -> bool | None:
         """
         Return the FINAL mute state of the player.
 
@@ -772,7 +806,7 @@ class Player(ABC):
         if mute_control == PLAYER_CONTROL_FAKE:
             return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
         if mute_control == PLAYER_CONTROL_NATIVE:
-            return self.volume_muted
+            return self._volume_muted
         if mute_control == PLAYER_CONTROL_NONE:
             return None
         if control := self.mass.players.get_player_control(mute_control):
@@ -781,7 +815,7 @@ class Player(ABC):
 
     @cached_property
     @final
-    def active_source_state(self) -> str | None:
+    def active_source(self) -> str | None:
         """
         Return the FINAL active source of the player.
 
@@ -789,21 +823,21 @@ class Player(ABC):
         based on any group memberships or source plugins that can be active.
         """
         # if the player is grouped/synced, use the active source of the group/parent player
-        if parent_player_id := (self.synced_to or self.active_group):
+        if parent_player_id := (self.active_group or self.synced_to):
             return parent_player_id
         # in case player's source is None, return the player_id (to indicate MA is active source)
-        return self.active_source or self.player_id
+        return self._active_source or self.player_id
 
     @cached_property
     @final
-    def source_list_state(self) -> UniqueList[PlayerSource]:
+    def source_list(self) -> UniqueList[PlayerSource]:
         """
         Return the FINAL source list of the player.
 
         This is a convenience property which calculates the final source list
         based on any group memberships or source plugins that can be active.
         """
-        sources = UniqueList(self.source_list)
+        sources = UniqueList(self._source_list)
         # always ensure the Music Assistant Queue is in the source list
         mass_source = next((x for x in sources if x.id == self.player_id), None)
         if mass_source is None:
@@ -819,10 +853,10 @@ class Player(ABC):
             )
             sources.append(mass_source)
         # if the player is grouped/synced, add the active source list of the group/parent player
-        if parent_player_id := (self.synced_to or self.active_group):
+        if parent_player_id := (self.active_group or self.synced_to):
             if parent_player := self.mass.players.get(parent_player_id):
-                for source in parent_player.source_list_state:
-                    if source.id == parent_player.active_source_state:
+                for source in parent_player.source_list:
+                    if source.id == parent_player.active_source:
                         sources.append(
                             PlayerSource(
                                 id=source.id,
@@ -885,7 +919,7 @@ class Player(ABC):
 
     @cached_property
     @final
-    def current_media_state(self) -> PlayerMedia | None:
+    def current_media(self) -> PlayerMedia | None:
         """
         Return the current media being played by the player.
 
@@ -893,16 +927,16 @@ class Player(ABC):
         based on any group memberships or source plugins that can be active.
         """
         # if the player is grouped/synced, use the current_media of the group/parent player
-        if parent_player_id := (self.synced_to or self.active_group):
+        if parent_player_id := (self.active_group or self.synced_to):
             if parent_player := self.mass.players.get(parent_player_id):
-                return parent_player.current_media_state
+                return parent_player.current_media
         # if a pluginsource is currently active, return those details
-        if self.active_source_state and (
-            source := self.mass.players.get_plugin_source(self.active_source_state)
+        if self.active_source and (
+            source := self.mass.players.get_plugin_source(self.active_source)
         ):
             return source.metadata
 
-        return None
+        return self._current_media
 
     @cached_property
     @final
@@ -954,7 +988,7 @@ class Player(ABC):
         for child_player in self.mass.players.iter_group_members(
             self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER
         ):
-            if (child_volume := child_player.volume_state) is None:
+            if (child_volume := child_player.volume_level) is None:
                 continue
             group_volume += child_volume
             active_players += 1
@@ -1211,8 +1245,8 @@ class Player(ABC):
             static_group_members=UniqueList(self.static_group_members),
             can_group_with=self.can_group_with,
             synced_to=self.synced_to,
-            active_source=self.active_source_state,
-            source_list=self.source_list_state,
+            active_source=self.active_source,
+            source_list=self.source_list,
             active_group=self.active_group,
             current_media=self.current_media,
             name=self.display_name,
index 788b10069852d980e386fc83cacff519dbd74097..72c5449259ea7e11caa87c1a67b30770d051616d 100644 (file)
@@ -57,7 +57,7 @@ class DemoPlayer(Player):
         return 5 if self.playback_state == PlaybackState.PLAYING else 30
 
     @property
-    def source_list(self) -> list[PlayerSource]:
+    def _source_list(self) -> list[PlayerSource]:
         """Return list of available (native) sources for this player."""
         # OPTIONAL - required only if you specified PlayerFeature.SELECT_SOURCE
         # this is an optional property that you can implement if your
@@ -191,6 +191,8 @@ class DemoPlayer(Player):
         logger = self.provider.logger.getChild(self.player_id)
         logger.info("Received STOP command on player %s", self.display_name)
         self._attr_playback_state = PlaybackState.IDLE
+        self._attr_active_source = None
+        self._attr_current_media = None
         self.update_state()
 
     async def pause(self) -> None:
index 08a14034063d3298d6c0a5455e9804f1e4d9ee49..f581c6d53f463aa8ba809bb5219ac8bcfe2426c8 100644 (file)
@@ -183,6 +183,9 @@ class AirPlayPlayer(Player):
         if self.raop_stream and self.raop_stream.session:
             # forward stop to the entire stream session
             await self.raop_stream.session.stop()
+        self._attr_active_source = None
+        self._attr_current_media = None
+        self.update_state()
 
     async def play(self) -> None:
         """Send PLAY (unpause) command to player."""
index 2282941382c866b35a4e4d1412e9721fe42116e6..36f2486baeeb57be1204183f8e1c8ed2956f835f 100644 (file)
@@ -122,7 +122,7 @@ class RaopStreamSession:
         if sync_leader.current_media:
             self.mass.call_later(
                 0.5,
-                sync_leader.play_media(sync_leader.current_media),
+                self.mass.players.cmd_resume(sync_leader.player_id),
                 task_id=f"resync_session_{sync_leader.player_id}",
             )
 
index 7e86d791e476b863ec51d11091853a336ecf741d..685a17cee82dd6e1b3fab0fc8d27ccc306dc5012 100644 (file)
@@ -288,6 +288,8 @@ class AlexaPlayer(Player):
     async def stop(self) -> None:
         """Handle STOP command on the player."""
         await self.api.stop()
+        self._attr_active_source = None
+        self._attr_current_media = None
         self._attr_playback_state = PlaybackState.IDLE
         self.update_state()
 
index 8c5c992dfe9e8241dc0911224ca4bf3c79e5e97f..078b4a5ce85a1f68527804fb13fa2608114d2b48 100644 (file)
@@ -110,6 +110,8 @@ class BluesoundPlayer(Player):
         if play_state == "stop":
             self._set_polling_dynamic()
         self._attr_playback_state = PlaybackState.IDLE
+        self._attr_active_source = None
+        self._attr_current_media = None
         self.update_state()
 
     async def play(self) -> None:
index 739d281459ea6d31bcaef4d787987a8e85afaca5..7ee7a8ade409725e194032411c346f86926283ef 100644 (file)
@@ -2,7 +2,7 @@
   "type": "player",
   "domain": "builtin_player",
   "stage": "alpha",
-  "name": "Music Assistant",
+  "name": "Builtin Web Player",
   "description": "Control playback and listen directly through the Music Assistant web interface.",
   "codeowners": ["@music-assistant"],
   "documentation": "https://music-assistant.io/player-support/builtin/",
index cb135027ea9ae02b1db55b9f2fe576a125032559..ee35ff185768f31db84ab31a56f91630fc63294d 100644 (file)
@@ -135,6 +135,9 @@ class BuiltinPlayer(Player):
             self.player_id,
             BuiltinPlayerEvent(type=BuiltinPlayerEventType.STOP),
         )
+        self._attr_active_source = None
+        self._attr_current_media = None
+        self.update_state()
 
     async def play(self) -> None:
         """Send PLAY command to player."""
index 980fd3fad6d442086e48f3a71f7a029439efe3da..4448d98b63d153339c9befb0c69fd721923e57fb 100644 (file)
@@ -420,9 +420,11 @@ class ChromecastPlayer(Player):
         elif status.player_is_paused:
             self._attr_playback_state = PlaybackState.PAUSED
             self._attr_current_media = None
+            self._attr_active_source = None
         else:
             self._attr_playback_state = PlaybackState.IDLE
             self._attr_current_media = None
+            self._attr_active_source = None
 
         # elapsed time
         self._attr_elapsed_time_last_updated = time.time()
index ddead9a268b35c98771696dd433efc91837da8e6..3e1d782b695edcc1715086d68ae1e463025b25f4 100644 (file)
@@ -84,6 +84,8 @@ class FullyKioskPlayer(Player):
         """Send STOP command to given player."""
         await self.fully_kiosk.stopSound()
         self._attr_playback_state = PlaybackState.IDLE
+        self._attr_active_source = None
+        self._attr_current_media = None
         self.update_state()
 
     async def play(self) -> None:
index 4b7ccc4e54bf2a9d0d89895d9cf333133f54c935..89c011cbf0d593d7ae766de004c39c2d34f710a7 100644 (file)
@@ -190,6 +190,10 @@ class HomeAssistantPlayer(Player):
                 raise
             if PlayerFeature.PAUSE in self.supported_features:
                 await self.pause()
+        finally:
+            self._attr_current_media = None
+            self._attr_active_source = None
+            self.update_state()
 
     async def volume_set(self, volume_level: int) -> None:
         """Handle VOLUME_SET command on the player."""
index 1d1e3dd521c01fc5d1bb7525b98f03a63b94d4e8..0b88f83e84d7644801aa742ce6ce66c98746b0de 100644 (file)
@@ -2,8 +2,8 @@
   "type": "player",
   "domain": "resonate",
   "stage": "alpha",
-  "name": "Resonate",
-  "description": "Resonate provider for Music Assistant.",
+  "name": "Resonate (WIP)",
+  "description": "Resonate (working title) is the next generation streaming protocol built by the Open Home Foundation. Follow the development on Discord to see how you can get involved.",
   "codeowners": ["@music-assistant"],
   "requirements": ["aioresonate==0.9.1"]
 }
index 65dd0405aad5d5b51793e1c09a52f56093c1516e..dd32093de43c3b393491e9b5ef30edab12af3882 100644 (file)
@@ -177,6 +177,9 @@ class ResonatePlayer(Player):
         self.logger.debug("Received STOP command on player %s", self.display_name)
         # We don't care if we stopped the stream or it was already stopped
         self.api.group.stop()
+        self._attr_active_source = None
+        self._attr_current_media = None
+        self.update_state()
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Play media command."""
index 7a1c3efb2f88001b281c71e4fd3ad76bec285131..168179b4673f8d6eee7921a712dd352803bb67ac 100644 (file)
@@ -146,6 +146,8 @@ class MediaAssistantPlayer(Player):
             logger = self.provider.logger.getChild(self.player_id)
             logger.info("Received STOP command on player %s", self.display_name)
             self._attr_playback_state = PlaybackState.IDLE
+            self._attr_active_source = None
+            self._attr_current_media = None
             self.update_state()
         except Exception:
             self.logger.error("Failed to send stop signal to: %s", self.name)
index 8debf9259bfc08f9d71cf5c90c43ace5d28fc967..ec111e19eb5107d91c49c1507fa56050753508b9 100644 (file)
@@ -155,6 +155,8 @@ class SonosPlayer(Player):
             return
         await asyncio.to_thread(self.soco.stop)
         self.mass.call_later(2, self.poll_speaker)
+        self._attr_active_source = None
+        self.update_state()
 
     async def play(self) -> None:
         """Send PLAY command to the player."""
index 0148cb1ddff82363c8c314797c2b0196f60fb01c..50f6133c514c299daf398e709f2e2039b9166606 100644 (file)
@@ -194,6 +194,8 @@ class SqueezelitePlayer(Player):
         async with TaskManager(self.mass) as tg:
             for client in self._get_sync_clients():
                 tg.create_task(client.stop())
+        self._attr_active_source = None
+        self.update_state()
 
     async def play(self) -> None:
         """Handle PLAY command on the player."""
@@ -350,7 +352,7 @@ class SqueezelitePlayer(Player):
         if players_added and self.current_media and self.playback_state == PlaybackState.PLAYING:
             # restart stream session if it was already playing
             # for now, we dont support late joining into an existing stream
-            self.mass.create_task(self.play_media(self.current_media))
+            self.mass.create_task(self.mass.players.cmd_resume(self.player_id))
 
     def handle_slim_event(self, event: SlimEvent) -> None:
         """Handle player event from slimproto server."""
index 94376d052ad09bf384c3dc712db488f00413270a..6e320589686b1ed0efb7a13b8b651b3e4efd0d5e 100644 (file)
@@ -1,7 +1,7 @@
 {
   "type": "player",
   "domain": "universal_group",
-  "stage": "stable",
+  "stage": "experimental",
   "name": "Universal Group Player",
   "description": "Create universal groups to group speakers of different protocols/ecosystems to play the same audio (but not in sync).",
   "codeowners": ["@music-assistant"],