From: Marcel van der Veldt Date: Thu, 2 Oct 2025 14:32:51 +0000 (+0200) Subject: Various follow up bugfixes regarding the players refactor (#2466) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=ce51a5df5dff0bf268d57becc89b9a282548d815;p=music-assistant-server.git Various follow up bugfixes regarding the players refactor (#2466) --- diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index 0c260c2c..ded831a5 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -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.""" diff --git a/music_assistant/controllers/players/sync_groups.py b/music_assistant/controllers/players/sync_groups.py index 0d95ca9e..4aad160e 100644 --- a/music_assistant/controllers/players/sync_groups.py +++ b/music_assistant/controllers/players/sync_groups.py @@ -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.""" diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 74dcfb57..3cec1424 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -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) diff --git a/music_assistant/models/core_controller.py b/music_assistant/models/core_controller.py index efff1497..be91dd25 100644 --- a/music_assistant/models/core_controller.py +++ b/music_assistant/models/core_controller.py @@ -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( diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 1c5b404a..aea3a410 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -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, diff --git a/music_assistant/providers/_demo_player_provider/player.py b/music_assistant/providers/_demo_player_provider/player.py index 788b1006..72c54492 100644 --- a/music_assistant/providers/_demo_player_provider/player.py +++ b/music_assistant/providers/_demo_player_provider/player.py @@ -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: diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 08a14034..f581c6d5 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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.""" diff --git a/music_assistant/providers/airplay/raop.py b/music_assistant/providers/airplay/raop.py index 22829413..36f2486b 100644 --- a/music_assistant/providers/airplay/raop.py +++ b/music_assistant/providers/airplay/raop.py @@ -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}", ) diff --git a/music_assistant/providers/alexa/__init__.py b/music_assistant/providers/alexa/__init__.py index 7e86d791..685a17ce 100644 --- a/music_assistant/providers/alexa/__init__.py +++ b/music_assistant/providers/alexa/__init__.py @@ -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() diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py index 8c5c992d..078b4a5c 100644 --- a/music_assistant/providers/bluesound/player.py +++ b/music_assistant/providers/bluesound/player.py @@ -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: diff --git a/music_assistant/providers/builtin_player/manifest.json b/music_assistant/providers/builtin_player/manifest.json index 739d2814..7ee7a8ad 100644 --- a/music_assistant/providers/builtin_player/manifest.json +++ b/music_assistant/providers/builtin_player/manifest.json @@ -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/", diff --git a/music_assistant/providers/builtin_player/player.py b/music_assistant/providers/builtin_player/player.py index cb135027..ee35ff18 100644 --- a/music_assistant/providers/builtin_player/player.py +++ b/music_assistant/providers/builtin_player/player.py @@ -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.""" diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index 980fd3fa..4448d98b 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -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() diff --git a/music_assistant/providers/fully_kiosk/player.py b/music_assistant/providers/fully_kiosk/player.py index ddead9a2..3e1d782b 100644 --- a/music_assistant/providers/fully_kiosk/player.py +++ b/music_assistant/providers/fully_kiosk/player.py @@ -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: diff --git a/music_assistant/providers/hass_players/player.py b/music_assistant/providers/hass_players/player.py index 4b7ccc4e..89c011cb 100644 --- a/music_assistant/providers/hass_players/player.py +++ b/music_assistant/providers/hass_players/player.py @@ -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.""" diff --git a/music_assistant/providers/resonate/manifest.json b/music_assistant/providers/resonate/manifest.json index 1d1e3dd5..0b88f83e 100644 --- a/music_assistant/providers/resonate/manifest.json +++ b/music_assistant/providers/resonate/manifest.json @@ -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"] } diff --git a/music_assistant/providers/resonate/player.py b/music_assistant/providers/resonate/player.py index 65dd0405..dd32093d 100644 --- a/music_assistant/providers/resonate/player.py +++ b/music_assistant/providers/resonate/player.py @@ -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.""" diff --git a/music_assistant/providers/roku_media_assistant/player.py b/music_assistant/providers/roku_media_assistant/player.py index 7a1c3efb..168179b4 100644 --- a/music_assistant/providers/roku_media_assistant/player.py +++ b/music_assistant/providers/roku_media_assistant/player.py @@ -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) diff --git a/music_assistant/providers/sonos_s1/player.py b/music_assistant/providers/sonos_s1/player.py index 8debf925..ec111e19 100644 --- a/music_assistant/providers/sonos_s1/player.py +++ b/music_assistant/providers/sonos_s1/player.py @@ -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.""" diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index 0148cb1d..50f6133c 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -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.""" diff --git a/music_assistant/providers/universal_group/manifest.json b/music_assistant/providers/universal_group/manifest.json index 94376d05..6e320589 100644 --- a/music_assistant/providers/universal_group/manifest.json +++ b/music_assistant/providers/universal_group/manifest.json @@ -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"],