"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
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.
@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.
# 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,
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 (
"""
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:
"""
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:
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)
# 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)
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:
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(
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."""
# and the music keeps playing uninterrupted.
"airplay",
"squeezelite",
- "resonate",
# TODO: Get this working with Sonos as well (need to handle range requests)
}
@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
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
# 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
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,
]
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)
# 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."""
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."""
"""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)
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
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(
"""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:
"""
"""
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."""
"""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.
@cached_property
@final
- def power_state(self) -> bool | None:
+ def powered(self) -> bool | None:
"""
Return the FINAL power state of the player.
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):
@cached_property
@final
- def volume_state(self) -> int | None:
+ def volume_level(self) -> int | None:
"""
Return the FINAL volume level of the player.
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):
@cached_property
@final
- def volume_muted_state(self) -> bool | None:
+ def volume_muted(self) -> bool | None:
"""
Return the FINAL mute state of the player.
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):
@cached_property
@final
- def active_source_state(self) -> str | None:
+ def active_source(self) -> str | None:
"""
Return the FINAL active source of the player.
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:
)
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,
@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.
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
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
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,
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
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:
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."""
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}",
)
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()
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:
"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/",
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."""
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()
"""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:
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."""
"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"]
}
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."""
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)
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."""
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."""
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."""
{
"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"],