from music_assistant.constants import (
ANNOUNCE_ALERT_FILE,
+ ATTR_ACTIVE_SOURCE,
ATTR_ANNOUNCEMENT_IN_PROGRESS,
ATTR_AVAILABLE,
ATTR_ELAPSED_TIME,
player,
required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
require_active=False,
- allow_native=True,
):
native_announce_support = True
else:
if removed_player := self.get_player(_removed_player_id):
removed_player.update_state()
+ # Handle external source takeover - detect when active_source changes to
+ # 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)
+
became_inactive = False
if ATTR_AVAILABLE in changed_values:
became_inactive = changed_values[ATTR_AVAILABLE][1] is False
# - the leader has DSP enabled
self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
+ def _handle_external_source_takeover(
+ self, player: Player, prev_source: str | None, new_source: str | None
+ ) -> None:
+ """
+ Handle when an external source takes over playback on a player.
+
+ When a player has an active grouped output protocol (e.g., AirPlay group) and
+ an external source (e.g., Spotify Connect, TV input) takes over playback,
+ we need to clear the active output protocol and ungroup the protocol players.
+
+ This prevents the situation where the player appears grouped via protocol
+ but is actually playing from a different source.
+
+ :param player: The player whose active_source changed.
+ :param prev_source: The previous active_source value.
+ :param new_source: The new active_source value.
+ """
+ # Only relevant for non-protocol players
+ if player.type == PlayerType.PROTOCOL:
+ 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
+
+ # Check if new source is external (not MA-managed)
+ if self._is_ma_managed_source(player, new_source):
+ return
+
+ # Get the active protocol player
+ protocol_player = self.get_player(player.active_output_protocol)
+ if not protocol_player:
+ return
+
+ # Only relevant if the protocol is grouped
+ if not self._is_protocol_grouped(protocol_player):
+ return
+
+ # External source took over while protocol was grouped - unbond
+ self.logger.info(
+ "External source '%s' took over on %s while grouped via protocol %s - "
+ "clearing active output protocol and ungrouping",
+ new_source,
+ player.display_name,
+ protocol_player.provider.domain,
+ )
+
+ # Clear active output protocol
+ player.set_active_output_protocol(None)
+
+ # Ungroup the protocol player (async task)
+ self.mass.create_task(protocol_player.ungroup())
+
+ def _is_ma_managed_source(self, player: Player, source: str | None) -> bool:
+ """
+ Check if a source is managed by Music Assistant.
+
+ MA-managed sources include:
+ - None (no source active)
+ - The player's own ID (MA queue)
+ - Any active queue ID
+ - Any plugin source ID
+
+ :param player: The player to check.
+ :param source: The source ID to check.
+ :return: True if the source is MA-managed, False if external.
+ """
+ if source is None:
+ return True
+
+ # Player's own ID means MA queue is active
+ if source == player.player_id:
+ return True
+
+ # Check if it's a known queue ID
+ if self.mass.player_queues.get(source):
+ return True
+
+ # Check if it's a plugin source
+ return any(plugin_source.id == source for plugin_source in self.get_plugin_sources())
+
def _schedule_update_all_players(self, delay: float = 2.0) -> None:
"""
Schedule a debounced update of all players' state.
player = self.get_player(player_id, raise_unavailable=True)
assert player is not None
if target_player := self._get_control_target(
- player, required_feature=PlayerFeature.ENQUEUE, require_active=True, allow_native=False
+ player,
+ required_feature=PlayerFeature.ENQUEUE,
+ require_active=True,
):
self.logger.debug(
"Redirecting enqueue command to protocol player %s",
target_player.provider.manifest.name,
)
- await self._handle_enqueue_next_media(target_player.player_id, media)
+ await target_player.enqueue_next_media(media)
return
if PlayerFeature.ENQUEUE not in player.state.supported_features:
)
raise PlayerCommandFailed(msg)
# Delegate to active protocol player if one is active
- if target_player := self._get_control_target(player, PlayerFeature.PAUSE, True):
+ if target_player := self._get_control_target(
+ player, PlayerFeature.PAUSE, require_active=True
+ ):
await target_player.play()
return
)
raise PlayerCommandFailed(msg)
# Delegate to active protocol player if one is active
- if not (target_player := self._get_control_target(player, PlayerFeature.PAUSE, True)):
+ if not (
+ target_player := self._get_control_target(
+ player, PlayerFeature.PAUSE, require_active=True
+ )
+ ):
# if player(protocol) does not support pause, we need to send stop
self.logger.debug(
"Player/protocol %s does not support pause, using STOP instead",
)
else:
# no crossfade, just a regular single item stream
- audio_input = buffered(
- self.get_queue_item_stream(
- queue_item=queue_item,
- pcm_format=pcm_format,
- seek_position=queue_item.streamdetails.seek_position,
- ),
- buffer_size=10,
- min_buffer_before_yield=2,
+ audio_input = self.get_queue_item_stream(
+ queue_item=queue_item,
+ pcm_format=pcm_format,
+ seek_position=queue_item.streamdetails.seek_position,
)
# stream the audio
# this final ffmpeg process in the chain will convert the raw, lossless PCM audio into
# the desired output format for the player including any player specific filter params
# such as channels mixing, DSP, resampling and, only if needed, encoding to lossy formats
- if queue_item.media_type == MediaType.RADIO:
- # keep very short buffer for radio streams
- # to keep them (more or less) realtime and prevent time outs
- read_rate_input_args = ["-readrate", "1.0", "-readrate_initial_burst", "2"]
- else:
- # just allow the player to buffer whatever it wants for single item streams
- read_rate_input_args = None
-
first_chunk_received = False
bytes_sent = 0
async for chunk in get_ffmpeg_stream(
input_format=pcm_format,
output_format=output_format,
),
- extra_input_args=read_rate_input_args,
):
try:
await resp.write(chunk)
if self.type == PlayerType.PROTOCOL:
return result
+ # Scenario 2: External source is active - don't include protocol-based grouping
+ # When an external source (e.g., Spotify Connect, TV) is active, grouping via
+ # protocols (AirPlay, Sendspin, etc.) wouldn't work - only native grouping is available.
+ if self._has_external_source_active():
+ return result
+
# Translate can_group_with from active linked protocol(s) and add to result
for linked in self.__attr_linked_protocols:
if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
result.add(parent_player)
return result
+ @final
+ def _has_external_source_active(self) -> bool:
+ """
+ Check if an external (non-MA-managed) source is currently active.
+
+ External sources include things like Spotify Connect, TV input, etc.
+ When an external source is active, protocol-based grouping is not available.
+
+ :return: True if an external source is active, False otherwise.
+ """
+ active_source = self.__final_active_source
+ if active_source is None:
+ return False
+
+ # Player's own ID means MA queue is (or was) active
+ if active_source == self.player_id:
+ return False
+
+ # Check if it's a known queue ID
+ if self.mass.player_queues.get(active_source):
+ return False
+
+ # Check if it's a plugin source - if not, it's an external source
+ return not any(
+ plugin_source.id == active_source
+ for plugin_source in self.mass.players.get_plugin_sources()
+ )
+
@final
def _expand_can_group_with(self) -> set[Player]:
"""