# something external while we have a grouped protocol active
if ATTR_ACTIVE_SOURCE in changed_values:
prev_source, new_source = changed_values[ATTR_ACTIVE_SOURCE]
- self._handle_external_source_takeover(player, prev_source, new_source)
-
+ task_id = f"external_source_takeover_{player_id}"
+ self.mass.call_later(
+ 3,
+ self._handle_external_source_takeover,
+ player,
+ prev_source,
+ new_source,
+ task_id=task_id,
+ )
became_inactive = False
if ATTR_AVAILABLE in changed_values:
became_inactive = changed_values[ATTR_AVAILABLE][1] is False
if player.type == PlayerType.PROTOCOL:
return
+ # Not a takeover if the player is not actively playing
+ if player.playback_state != PlaybackState.PLAYING:
+ return
+
# Only relevant if we have an active output protocol (not native)
if not player.active_output_protocol or player.active_output_protocol == "native":
return
if not protocol_player:
return
- # Only relevant if the protocol is grouped
- if not self._is_protocol_grouped(protocol_player):
+ # If the source matches the active protocol's domain, it's expected - not a takeover
+ # e.g., source "airplay" when using AirPlay protocol is normal
+ if new_source and new_source.lower() == protocol_player.provider.domain.lower():
return
- # External source took over while protocol was grouped - unbond
+ # Confirmed external source takeover
self.logger.info(
"External source '%s' took over on %s while grouped via protocol %s - "
"clearing active output protocol and ungrouping",
Check if a source is managed by Music Assistant.
MA-managed sources include:
- - None (no source active)
+ - None (=autodetect, no source explicitly set by player)
- The player's own ID (MA queue)
- Any active queue ID
- Any plugin source ID
if TYPE_CHECKING:
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, PlayerConfig
+ from music_assistant_models.player_queue import PlayerQueue
from .player_provider import PlayerProvider
self._extra_data: dict[str, Any] = {}
self._extra_attributes: dict[str, Any] = {}
self._on_unload_callbacks: list[Callable[[], None]] = []
- self.__active_mass_source = player_id
+ self.__active_mass_source: str | None = None
# The PlayerState is the (snapshotted) final state of the player
# after applying any config overrides and other transformations,
# such as the display name and player controls.
and self._state.playback_state == PlaybackState.IDLE
):
self.__stop_called = True
- self.__active_mass_source = None
+ # when we're going to idle,
+ # we want to reset the active mass source after a short delay
+ # this is done using a timer which gets reset if the player starts playing again
+ # before the timer is up, using the task_id
+ self.mass.call_later(
+ 5, self.set_active_mass_source, None, task_id=f"set_mass_source_{self.player_id}"
+ )
return get_changed_dataclass_values(
prev_state,
and (
protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
)
+ and protocol_player.playback_state != PlaybackState.IDLE
):
return (
protocol_player.state.playback_state,
protocol_player.state.elapsed_time,
protocol_player.state.elapsed_time_last_updated,
)
- # if we're synced/grouped, use the parent player's state
- parent_id = self.__final_synced_to or self.__final_active_group
+ # If we're synced, use the syncleader state for playback state and elapsed time
+ # NOTE: Don't do this for the active group player,
+ # because the group player relies on the sync leader for state info.
+ parent_id = self.__final_synced_to
if parent_id and (parent_player := self.mass.players.get_player(parent_id)):
return (
parent_player.state.playback_state,
parent_player := self.mass.players.get_player(parent_player_id)
):
return parent_player.state.current_media
+ return None # if parent player not found, return None for current media
# if this is a protocol player, use the current_media of the parent player
if self.type == PlayerType.PROTOCOL and self.__attr_protocol_parent_id:
if parent_player := self.mass.players.get_player(self.__attr_protocol_parent_id):
elapsed_time_last_updated=source.metadata.elapsed_time_last_updated,
)
# if MA queue is active, return those details
- active_queue = None
- if self.current_media and self.current_media.source_id:
- active_queue = self.mass.player_queues.get(self.current_media.source_id)
+ active_queue: PlayerQueue | None = None
if not active_queue and active_source:
active_queue = self.mass.player_queues.get(active_source)
if not active_queue and self.active_source is None:
active_queue = self.mass.player_queues.get(self.player_id)
-
if active_queue and (current_item := active_queue.current_item):
item_image_url = (
# the image format needs to be 500x500 jpeg for maximum compatibility with players
if parent_player_id := (self.__final_synced_to or self.__final_active_group):
if parent_player := self.mass.players.get_player(parent_player_id):
return parent_player.state.active_source
- # always prioritize active MA source
- # (it is set on playback start and cleared on stop)
- if self.__active_mass_source:
- return self.__active_mass_source
+ return None # should not happen but just in case
+ if self.type == PlayerType.PROTOCOL:
+ if self.protocol_parent_id and (
+ parent_player := self.mass.players.get_player(self.protocol_parent_id)
+ ):
+ # if this is a protocol player, use the active source of the parent player
+ return parent_player.state.active_source
+ # fallback to None here if parent player not found,
+ # protocol players should not have an active source themselves
+ return None
# if a plugin source is active that belongs to this player, return that
for plugin_source in self.mass.players.get_plugin_sources():
if plugin_source.in_use_by == self.player_id:
return plugin_source.id
+ output_protocol_domain: str | None = None
+ if self.active_output_protocol and self.active_output_protocol != "native":
+ if protocol_player := self.mass.players.get_player(self.active_output_protocol):
+ output_protocol_domain = protocol_player.provider.domain
# active source as reported by the player itself, but only if playing/paused
- if self.playback_state != PlaybackState.IDLE and self.active_source:
+ if (
+ self.playback_state != PlaybackState.IDLE
+ and self.active_source
+ # try to catch cases where player reports an active source
+ # that is actually from an active output protocol (e.g. AirPlay)
+ and self.active_source.lower() != output_protocol_domain
+ ):
return self.active_source
# return the (last) known MA source
- return self.__last_active_mass_source
+ return self.__active_mass_source
@final
def _translate_protocol_ids_to_visible(self, player_ids: set[str]) -> set[Player]:
# This is to keep track of the last active MA source for the player,
# so we can restore it when needed (e.g. after switching to a plugin source).
__active_mass_source: str | None = None
- __last_active_mass_source: str | None = None
@final
- def set_active_mass_source(self, value: str) -> None:
+ def set_active_mass_source(self, value: str | None) -> None:
"""
- Set the id of the active mass source.
+ Set the id of the (last) active mass source.
This is to keep track of the last active MA source for the player,
so we can restore it when needed (e.g. after switching to a plugin source).
"""
+ self.mass.cancel_timer(f"set_mass_source_{self.player_id}")
self.__active_mass_source = value
- self.__last_active_mass_source = value
self.update_state()
__stop_called: bool = False
self.mass.config.get_raw_player_config_value(player_id, CONF_IGNORE_VOLUME, False)
or player.device_info.manufacturer.lower() == "apple"
)
- active_queue = self.mass.players.get_active_queue(player)
- if not active_queue:
- self.logger.warning(
- "DACP request for %s (%s) but no active queue found, ignoring request",
- player.display_name,
- player_id,
- )
- return
if path == "/ctrl-int/1/nextitem":
- self.mass.create_task(self.mass.player_queues.next(active_queue.queue_id))
+ self.mass.create_task(self.mass.players.cmd_next_track(player_id))
elif path == "/ctrl-int/1/previtem":
- self.mass.create_task(self.mass.player_queues.previous(active_queue.queue_id))
+ self.mass.create_task(self.mass.players.cmd_previous_track(player_id))
elif path == "/ctrl-int/1/play":
# sometimes this request is sent by a device as confirmation of a play command
# we ignore this if the player is already playing
if player.playback_state != PlaybackState.PLAYING:
- self.mass.create_task(self.mass.player_queues.play(active_queue.queue_id))
+ self.mass.create_task(self.mass.players.cmd_play(player_id))
elif path == "/ctrl-int/1/playpause":
- self.mass.create_task(self.mass.player_queues.play_pause(active_queue.queue_id))
+ self.mass.create_task(self.mass.players.cmd_play_pause(player_id))
elif path == "/ctrl-int/1/stop":
- self.mass.create_task(self.mass.player_queues.stop(active_queue.queue_id))
+ self.mass.create_task(self.mass.players.cmd_stop(player_id))
elif path == "/ctrl-int/1/volumeup":
self.mass.create_task(self.mass.players.cmd_volume_up(player_id))
elif path == "/ctrl-int/1/volumedown":
self.mass.create_task(self.mass.players.cmd_volume_down(player_id))
elif path == "/ctrl-int/1/shuffle_songs":
- queue = self.mass.player_queues.get(player_id)
- if not queue:
+ active_queue = self.mass.players.get_active_queue(player)
+ if not active_queue:
return
await self.mass.player_queues.set_shuffle(
- active_queue.queue_id, not queue.shuffle_enabled
+ active_queue.queue_id, not active_queue.shuffle_enabled
)
elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):
# sometimes this request is sent by a device as confirmation of a play command
# we ignore this if the player is already playing
if player.playback_state == PlaybackState.PLAYING:
- self.mass.create_task(self.mass.player_queues.pause(active_queue.queue_id))
+ self.mass.create_task(self.mass.players.cmd_pause(player_id))
elif "dmcp.device-volume=" in path and not ignore_volume_report:
# This is a bit annoying as this can be either the device confirming a new volume
# we've sent or the device requesting a new volume itself.
)
self.logger.debug(self.status)
- mass_active = self.mass.streams.base_url
- if self.status.stream_url and mass_active in self.status.stream_url:
- self._attr_active_source = self.player_id
+ mass_url = self.mass.streams.base_url
+ if self.status.stream_url and mass_url in self.status.stream_url:
+ self._attr_active_source = None
elif player_source := PLAYER_SOURCE_MAP.get(self.status.input_id):
self._attr_active_source = self.status.input_id
self._attr_source_list.append(player_source)
raise PlayerUnavailableError("Failed to launch Sendspin Cast App")
else:
await self._launch_app()
- self._attr_active_source = self.player_id
+ self._attr_active_source = None
else:
self._attr_active_source = None
await asyncio.to_thread(self.cc.quit_app)
if group_player:
self._attr_active_source = group_player.active_source or group_player.player_id
elif self.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER):
- self._attr_active_source = self.player_id
+ self._attr_active_source = None
else:
app_name = self.cc.app_display_name or "Unknown App"
app_id = app_name.lower().replace(" ", "_")
child._attr_current_media = self._attr_current_media
child._attr_elapsed_time = self._attr_elapsed_time
child._attr_elapsed_time_last_updated = self._attr_elapsed_time_last_updated
- child._attr_active_source = self._active_source
+ child._attr_active_source = self.active_source
self.mass.loop.call_soon_threadsafe(child.update_state)
self.mass.loop.call_soon_threadsafe(self.update_state)
# so we later only react to state updates that include it.
media_content_id = self._hass_attributes.get("media_content_id", "")
is_ma_playback = media_content_id.startswith(self.mass.streams.base_url)
-
media_title = self._hass_attributes.get("media_title")
if media_content_id and is_ma_playback:
# MA playback - ensure active_source points to player_id for queue lookup.
# The actual current_media will be set by MA's queue controller.
- self._attr_active_source = self.player_id
+ self._attr_active_source = None
elif (
media_content_id
and media_title
if self.client.player.is_passive:
self.logger.debug("Ignore PAUSE command: Player is synced to another player.")
return
- active_source = self._attr_active_source
+ active_source = self.state.active_source
if self.mass.player_queues.get(active_source):
# Sonos seems to be bugged when playing our queue tracks and we send pause,
# it can't resume the current track and simply aborts/skips it
if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]:
self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
elif active_service == MusicService.MUSIC_ASSISTANT:
- if (object_id := container.get("id", {}).get("objectId")) and object_id.startswith(
- "mass:"
- ):
- self._attr_active_source = object_id.split(":")[1]
- else:
- self._attr_active_source = None
+ # setting active source to None is fine
+ self._attr_active_source = None
# its playing some service we did not yet map
elif container and container.get("service", {}).get("name"):
self._attr_active_source = container["service"]["name"]
# Workaround for Sonos AirPlay ungrouping bug: when AirPlay playback starts
# on a Sonos speaker that has native group members, Sonos dissolves the group.
- # We capture the group state here and restore it via AirPlay protocol after a delay.
+ # We capture the group state here and restore it after a delay.
self.logger.debug(
"AirPlay playback starting on %s with native group members %s - "
- "scheduling restoration to avoid Sonos ungrouping bug",
+ "scheduling restoration to work around Sonos ungrouping bug",
self.name,
current_members,
)
async def _restore_airplay_group() -> None:
try:
+ self.logger.info(
+ "Restoring AirPlay group for %s with members %s",
+ self.name,
+ members_to_restore,
+ )
# we call set_members on the PlayerController here so it
# can try to regroup via the preferred protocol (which may be AirPlay),
- await self.mass.players.cmd_set_members(
- self.player_id, player_ids_to_add=members_to_restore
- )
+ await self.set_members(player_ids_to_add=members_to_restore)
except Exception as err:
self.logger.warning("Failed to restore AirPlay group: %s", err)
source_id=metadata.get("source_id"),
queue_item_id=metadata.get("queue_item_id"),
)
- # Set active source from metadata if available, otherwise use player_id
- self._attr_active_source = metadata.get("source_id") or self.player_id
else:
self._attr_current_media = None
- self._attr_active_source = self.player_id
async def _handle_play_url_for_slimplayer(
self,
EXTRA_FEATURES_FROM_MEMBERS: Final[set[PlayerFeature]] = {
PlayerFeature.ENQUEUE,
PlayerFeature.GAPLESS_PLAYBACK,
- PlayerFeature.PAUSE,
PlayerFeature.VOLUME_SET,
PlayerFeature.VOLUME_MUTE,
PlayerFeature.MULTI_DEVICE_DSP,
"""Return when the elapsed time was last updated."""
return self.sync_leader.state.elapsed_time_last_updated if self.sync_leader else None
- @property
- def current_media(self) -> PlayerMedia | None:
- """Return the current media item (if any) loaded in the player."""
- return self.sync_leader.state.current_media if self.sync_leader else None
-
- @property
- def active_source(self) -> str | None:
- """Return the active source id (if any) of the player."""
- return self.sync_leader.active_source if self.sync_leader else None
-
@property
def can_group_with(self) -> set[str]:
"""Return the id's of players this player can group with."""
async def stop(self) -> None:
"""Send STOP command to given player."""
+ self._attr_current_media = None
+ self._attr_active_source = None
if sync_leader := self.sync_leader:
# Use internal handler to bypass group redirect logic and avoid infinite loop
# (sync_leader is part of this group, so redirect would loop back here)
# Use internal handler to bypass group redirect logic and avoid infinite loop
await self.mass.players._handle_cmd_play(sync_leader.player_id)
- async def pause(self) -> None:
- """Send PAUSE command to given player."""
- if sync_leader := self.sync_leader:
- # Use internal handler to bypass group redirect logic and avoid infinite loop
- await self.mass.players._handle_cmd_pause(sync_leader.player_id)
-
async def play_media(self, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA on given player."""
+ self._attr_current_media = media
+ self._attr_active_source = media.source_id if media.source_id else None
if not self.sync_leader:
await self._form_syncgroup()
# simply forward the command to the sync leader
if not can_group_with:
# first member, add all its compatible players to the can_group_with set
can_group_with = set(member.state.can_group_with)
- if member_id not in can_group_with:
+ elif member_id not in can_group_with:
# member is not compatible with the current group, skip it
continue
final_members.append(member_id)