Adds a "virtual" player provider to create a universal group from all player types.
* Supports sync of all supported players (within the same ecosystem)
* Sync between different ecocystems or players that do not support sync is not implemented
"source.organizeImports": true
}
},
- "python.formatting.provider": "black"
+ "python.formatting.provider": "black",
}
CONF_EQ_MID,
CONF_EQ_TREBLE,
CONF_FLOW_MODE,
+ CONF_GROUPED_POWER_ON,
+ CONF_HIDE_GROUP_CHILDS,
CONF_LOG_LEVEL,
CONF_OUTPUT_CHANNELS,
CONF_OUTPUT_CODEC,
ENCRYPT_CALLBACK: callable[[str], str] | None = None
DECRYPT_CALLBACK: callable[[str], str] | None = None
-ConfigValueType = str | int | float | bool | None
+ConfigValueType = str | int | float | bool | list[str] | list[int] | None
ConfigEntryTypeMap = {
ConfigEntryType.BOOLEAN: bool,
allow_none: bool = True,
) -> ConfigValueType:
"""Parse value from the config entry details and plain value."""
- expected_type = ConfigEntryTypeMap.get(self.type, NoneType)
+ expected_type = list if self.multi_value else ConfigEntryTypeMap.get(self.type, NoneType)
if value is None:
value = self.default_value
if value is None and (not self.required or allow_none):
default_name: str | None = None
-DEFAULT_PROVIDER_CONFIG_ENTRIES = (
- ConfigEntry(
- key=CONF_LOG_LEVEL,
- type=ConfigEntryType.STRING,
- label="Log level",
- options=[
- ConfigValueOption("global", "GLOBAL"),
- ConfigValueOption("info", "INFO"),
- ConfigValueOption("warning", "WARNING"),
- ConfigValueOption("error", "ERROR"),
- ConfigValueOption("debug", "DEBUG"),
- ],
- default_value="GLOBAL",
- description="Set the log verbosity for this provider",
- advanced=True,
- ),
+CONF_ENTRY_LOG_LEVEL = ConfigEntry(
+ key=CONF_LOG_LEVEL,
+ type=ConfigEntryType.STRING,
+ label="Log level",
+ options=[
+ ConfigValueOption("global", "GLOBAL"),
+ ConfigValueOption("info", "INFO"),
+ ConfigValueOption("warning", "WARNING"),
+ ConfigValueOption("error", "ERROR"),
+ ConfigValueOption("debug", "DEBUG"),
+ ],
+ default_value="GLOBAL",
+ description="Set the log verbosity for this provider",
+ advanced=True,
)
+DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
-DEFAULT_PLAYER_CONFIG_ENTRIES = (
- ConfigEntry(
- key=CONF_VOLUME_NORMALISATION,
- type=ConfigEntryType.BOOLEAN,
- label="Enable volume normalization (EBU-R128 based)",
- default_value=True,
- description="Enable volume normalization based on the EBU-R128 "
- "standard without affecting dynamic range",
- ),
- ConfigEntry(
- key=CONF_FLOW_MODE,
- type=ConfigEntryType.BOOLEAN,
- label="Enable queue flow mode",
- default_value=False,
- description='Enable "flow" mode where all queue tracks are sent as a continuous '
- "audio stream. Use for players that do not natively support gapless and/or "
- "crossfading or if the player has trouble transitioning between tracks.",
- advanced=True,
- ),
- ConfigEntry(
- key=CONF_VOLUME_NORMALISATION_TARGET,
- type=ConfigEntryType.INTEGER,
- range=(-30, 0),
- default_value=-14,
- label="Target level for volume normalisation",
- description="Adjust average (perceived) loudness to this target level, "
- "default is -14 LUFS",
- depends_on=CONF_VOLUME_NORMALISATION,
- advanced=True,
- ),
- ConfigEntry(
- key=CONF_EQ_BASS,
- type=ConfigEntryType.INTEGER,
- range=(-10, 10),
- default_value=0,
- label="Equalizer: bass",
- description="Use the builtin basic equalizer to adjust the bass of audio.",
- advanced=True,
- ),
- ConfigEntry(
- key=CONF_EQ_MID,
- type=ConfigEntryType.INTEGER,
- range=(-10, 10),
- default_value=0,
- label="Equalizer: midrange",
- description="Use the builtin basic equalizer to adjust the midrange of audio.",
- advanced=True,
- ),
- ConfigEntry(
- key=CONF_EQ_TREBLE,
- type=ConfigEntryType.INTEGER,
- range=(-10, 10),
- default_value=0,
- label="Equalizer: treble",
- description="Use the builtin basic equalizer to adjust the treble of audio.",
- advanced=True,
- ),
- ConfigEntry(
- key=CONF_OUTPUT_CHANNELS,
- type=ConfigEntryType.STRING,
- options=[
- ConfigValueOption("Stereo (both channels)", "stereo"),
- ConfigValueOption("Left channel", "left"),
- ConfigValueOption("Right channel", "right"),
- ConfigValueOption("Mono (both channels)", "mono"),
- ],
- default_value="stereo",
- label="Output Channel Mode",
- description="You can configure this player to play only the left or right channel, "
- "for example to a create a stereo pair with 2 players.",
- advanced=True,
- ),
-)
+# some reusable player config entries
CONF_ENTRY_OUTPUT_CODEC = ConfigEntry(
key=CONF_OUTPUT_CODEC,
"Change this setting only if needed for your device/environment.",
advanced=True,
)
+
+CONF_ENTRY_FLOW_MODE = ConfigEntry(
+ key=CONF_FLOW_MODE,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable queue flow mode",
+ default_value=False,
+ description='Enable "flow" mode where all queue tracks are sent as a continuous '
+ "audio stream. Use for players that do not natively support gapless and/or "
+ "crossfading or if the player has trouble transitioning between tracks.",
+ advanced=False,
+)
+
+CONF_ENTRY_OUTPUT_CHANNELS = ConfigEntry(
+ key=CONF_OUTPUT_CHANNELS,
+ type=ConfigEntryType.STRING,
+ options=[
+ ConfigValueOption("Stereo (both channels)", "stereo"),
+ ConfigValueOption("Left channel", "left"),
+ ConfigValueOption("Right channel", "right"),
+ ConfigValueOption("Mono (both channels)", "mono"),
+ ],
+ default_value="stereo",
+ label="Output Channel Mode",
+ description="You can configure this player to play only the left or right channel, "
+ "for example to a create a stereo pair with 2 players.",
+ advanced=True,
+)
+
+CONF_ENTRY_VOLUME_NORMALISATION = ConfigEntry(
+ key=CONF_VOLUME_NORMALISATION,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable volume normalization (EBU-R128 based)",
+ default_value=True,
+ description="Enable volume normalization based on the EBU-R128 "
+ "standard without affecting dynamic range",
+)
+
+CONF_ENTRY_VOLUME_NORMALISATION_TARGET = ConfigEntry(
+ key=CONF_VOLUME_NORMALISATION_TARGET,
+ type=ConfigEntryType.INTEGER,
+ range=(-30, 0),
+ default_value=-14,
+ label="Target level for volume normalisation",
+ description="Adjust average (perceived) loudness to this target level, " "default is -14 LUFS",
+ depends_on=CONF_VOLUME_NORMALISATION,
+ advanced=True,
+)
+
+CONF_ENTRY_EQ_BASS = ConfigEntry(
+ key=CONF_EQ_BASS,
+ type=ConfigEntryType.INTEGER,
+ range=(-10, 10),
+ default_value=0,
+ label="Equalizer: bass",
+ description="Use the builtin basic equalizer to adjust the bass of audio.",
+ advanced=True,
+)
+
+CONF_ENTRY_EQ_MID = ConfigEntry(
+ key=CONF_EQ_MID,
+ type=ConfigEntryType.INTEGER,
+ range=(-10, 10),
+ default_value=0,
+ label="Equalizer: midrange",
+ description="Use the builtin basic equalizer to adjust the midrange of audio.",
+ advanced=True,
+)
+
+CONF_ENTRY_EQ_TREBLE = ConfigEntry(
+ key=CONF_EQ_TREBLE,
+ type=ConfigEntryType.INTEGER,
+ range=(-10, 10),
+ default_value=0,
+ label="Equalizer: treble",
+ description="Use the builtin basic equalizer to adjust the treble of audio.",
+ advanced=True,
+)
+
+CONF_ENTRY_HIDE_GROUP_MEMBERS = ConfigEntry(
+ key=CONF_HIDE_GROUP_CHILDS,
+ type=ConfigEntryType.STRING,
+ options=[
+ ConfigValueOption("Always", "always"),
+ ConfigValueOption("Only if the group is active/powered", "active"),
+ ConfigValueOption("Never", "never"),
+ ],
+ default_value="active",
+ label="Hide playergroup members in UI",
+ description="Hide the individual player entry for the members of this group "
+ "in the user interface.",
+ advanced=False,
+)
+
+CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry(
+ key=CONF_GROUPED_POWER_ON,
+ type=ConfigEntryType.BOOLEAN,
+ default_value=True,
+ label="Forced Power ON of all group members",
+ description="Power ON all child players when the group player is powered on "
+ "(or playback started). \n"
+ "If this setting is disabled, playback will only start on players that "
+ "are already powered ON at the time of playback start.\n"
+ "When turning OFF the group player, all group members are turned off, "
+ "regardless of this setting.",
+ advanced=False,
+)
+
+DEFAULT_PLAYER_CONFIG_ENTRIES = (
+ CONF_ENTRY_VOLUME_NORMALISATION,
+ CONF_ENTRY_FLOW_MODE,
+ CONF_ENTRY_VOLUME_NORMALISATION_TARGET,
+ CONF_ENTRY_EQ_BASS,
+ CONF_ENTRY_EQ_MID,
+ CONF_ENTRY_EQ_TREBLE,
+ CONF_ENTRY_OUTPUT_CHANNELS,
+)
class DeviceInfo(DataClassDictMixin):
"""Model for a player's deviceinfo."""
- model: str = "unknown"
- address: str = "unknown"
- manufacturer: str = "unknown"
+ model: str = "Unknown model"
+ address: str = ""
+ manufacturer: str = "Unknown Manufacturer"
@dataclass
from mashumaro import DataClassDictMixin
from .enums import MediaType
-from .media_items import ItemMapping, MediaItemImage, Radio, StreamDetails, Track
+from .media_items import ItemMapping, MediaItemImage, StreamDetails
if TYPE_CHECKING:
- pass
+ from .media_items import Album, Radio, Track
@dataclass
return None
if media_item.image:
return media_item.image
- if isinstance(media_item, Track) and media_item.album and getattr(media_item.album, "image"):
+ if isinstance(media_item, Track) and isinstance(media_item.album, Album):
return media_item.album.image
return None
CONF_LOG_LEVEL: Final[str] = "log_level"
CONF_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs"
CONF_OUTPUT_CODEC: Final[str] = "output_codec"
+CONF_GROUPED_POWER_ON: Final[str] = "grouped_power_on"
# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
raw_conf["available"] = False
raw_conf["name"] = raw_conf.get("name")
raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
- entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov_entries
+ prov_entries = prov.get_player_config_entries(player_id)
+ prov_entries_keys = {x.key for x in prov_entries}
+ # combine provider defined entries with default player config entries
+ entries = prov_entries + tuple(
+ x for x in DEFAULT_PLAYER_CONFIG_ENTRIES if x.key not in prov_entries_keys
+ )
return PlayerConfig.parse(entries, raw_conf)
raise KeyError(f"No config found for player id {player_id}")
@api_command("config/players/get_value")
def get_player_config_value(self, player_id: str, key: str) -> ConfigValueType:
"""Return single configentry value for a player."""
- conf = self.get(f"{CONF_PLAYERS}/{player_id}")
- if not conf:
- player = self.mass.players.get(player_id, True)
- conf = {"provider": player.provider, "player_id": player_id, "values": {}}
- prov = self.mass.get_provider(conf["provider"])
- entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov.get_player_config_entries(player_id)
- for entry in entries:
- if entry.key == key:
- # always create a copy to prevent we're altering the base object
- return ConfigEntry.from_dict(entry.to_dict()).parse_value(conf["values"].get(key))
- raise KeyError(f"ConfigEntry {key} is invalid")
+ conf = self.get_player_config(player_id)
+ # always create a copy to prevent we're altering the base object
+ return (
+ conf.values[key].value
+ if conf.values[key].value is not None
+ else conf.values[key].default_value
+ )
@api_command("config/players/save")
def save_player_config(
# track is already played for > 90% - skip to next
resume_item = next_item
resume_pos = 0
+ elif queue.current_index is not None and len(queue_items) > 0:
+ resume_item = self.get_item(queue_id, queue.current_index)
+ resume_pos = 0
elif queue.current_index is None and len(queue_items) > 0:
# items available in queue but no previous track, start at 0
resume_item = self.get_item(queue_id, 0)
cur_item = self.get_item(queue.queue_id, cur_index)
next_index = self.get_next_index(queue.queue_id, cur_index)
next_item = self.get_item(queue.queue_id, next_index)
- if not next_item:
+ if not cur_item or not next_item:
raise QueueEmpty("No more tracks left in the queue.")
queue.index_in_buffer = next_index
# work out crossfade
player.name,
)
self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
+ # always call update to fix special attributes like display name, group volume etc.
+ self.update(player.player_id)
@api_command("players/register_or_update")
def register_or_update(self, player: Player) -> None:
for child_player_id in player.group_childs:
if child_player_id == player_id:
continue
- self.update(child_player_id, skip_forward=True, force_update=force_update)
+ self.update(child_player_id, skip_forward=True)
# update group player(s) when child updates
for group_player in self._get_player_groups(player_id):
- self.update(group_player.player_id, skip_forward=True, force_update=force_update)
+ player_prov = self.get_player_provider(group_player.player_id)
+ player_prov.on_child_state(group_player.player_id, player, changed_keys)
def get_player_provider(self, player_id: str) -> PlayerProvider:
"""Return PlayerProvider for given player."""
player = self.get(player_id, True)
if player.powered == powered:
return
- # stop player at power off
- if not powered and player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
+ # send stop at power off
+ if not powered:
await self.cmd_stop(player_id)
+ # unsync player at power off
+ if not powered and player.synced_to is not None:
+ await self.cmd_unsync(player_id)
if PlayerFeature.POWER not in player.supported_features:
player.powered = powered
self.update(player_id)
group_player = self.get(player_id, True)
assert group_player
# handle group volume by only applying the volume to powered members
- cur_volume = group_player.volume_level
+ cur_volume = group_player.group_volume
new_volume = volume_level
volume_dif = new_volume - cur_volume
volume_dif_percent = 1 + new_volume / 100 if cur_volume == 0 else volume_dif / cur_volume
"""Return the active_source id for given player."""
# if player is synced, return master/group leader
if player.synced_to and player.synced_to in self._players:
- return self._get_active_source(self.get(player.synced_to))
+ return player.synced_to
# iterate player groups to find out if one is playing
if group_players := self._get_player_groups(player.player_id):
# prefer the first playing (or paused) group parent
for group_player in group_players:
if group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
- return group_player.active_source
+ return group_player.player_id
# fallback to the first powered group player
for group_player in group_players:
if group_player.powered:
- return group_player.active_source
- # defaults to the player's own player id
+ return group_player.player_id
+ # guess source from player's current url
if player.current_url:
if self.mass.webserver.base_url in player.current_url:
return player.player_id
- elif ":" in player.current_url:
+ if ":" in player.current_url:
# extract source from uri/url
return player.current_url.split(":")[0]
return player.current_item_id or player.current_url
- elif not player.powered:
- # reset active source when player powers off
- return player.player_id
- return player.active_source
+ # defaults to the player's own player id
+ return player.player_id
def _get_group_volume_level(self, player: Player) -> int:
"""Calculate a group volume from the grouped members."""
meta_int = int(headers.get("icy-metaint", "0"))
# stream with ICY Metadata
if meta_int:
+ LOGGER.debug("Start streaming radio with ICY metadata from url %s", url)
while True:
- audio_chunk = await resp.content.readexactly(meta_int)
- yield audio_chunk
- meta_byte = await resp.content.readexactly(1)
- meta_length = ord(meta_byte) * 16
- meta_data = await resp.content.readexactly(meta_length)
+ try:
+ audio_chunk = await resp.content.readexactly(meta_int)
+ yield audio_chunk
+ meta_byte = await resp.content.readexactly(1)
+ meta_length = ord(meta_byte) * 16
+ meta_data = await resp.content.readexactly(meta_length)
+ except asyncio.exceptions.IncompleteReadError:
+ break
if not meta_data:
continue
meta_data = meta_data.rstrip(b"\0")
streamdetails.stream_title = stream_title
# Regular HTTP stream
else:
+ LOGGER.debug("Start streaming radio without ICY metadata from url %s", url)
async for chunk in resp.content.iter_any():
yield chunk
+ LOGGER.debug("Finished streaming radio from url %s", url)
async def get_http_stream(
If the player does not need any polling, simply do not override this method.
"""
+ def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None:
+ """Call when the state of a child player updates."""
+ # default implementation: simply update the state of the group player
+ self.mass.players.update(player_id, skip_forward=True)
+
# DO NOT OVERRIDE BELOW
@property
from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
from music_assistant.common.models.config_entries import (
+ CONF_ENTRY_HIDE_GROUP_MEMBERS,
CONF_ENTRY_OUTPUT_CODEC,
ConfigEntry,
- ConfigValueOption,
ConfigValueType,
)
from music_assistant.common.models.enums import (
from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
from music_assistant.common.models.player import DeviceInfo, Player
from music_assistant.common.models.queue_item import QueueItem
-from music_assistant.constants import (
- CONF_HIDE_GROUP_CHILDS,
- CONF_OUTPUT_CODEC,
- CONF_PLAYERS,
- MASS_LOGO_ONLINE,
-)
+from music_assistant.constants import CONF_OUTPUT_CODEC, CONF_PLAYERS, MASS_LOGO_ONLINE
from music_assistant.server.models.player_provider import PlayerProvider
from .helpers import CastStatusListener, ChromecastInfo
and cast_player.cast_info.is_audio_group
and not cast_player.cast_info.is_multichannel_group
):
- entries = entries + (
- ConfigEntry(
- key=CONF_HIDE_GROUP_CHILDS,
- type=ConfigEntryType.STRING,
- options=[
- ConfigValueOption("Always", "always"),
- ConfigValueOption("Only if the group is active/powered", "active"),
- ConfigValueOption("Never", "never"),
- ],
- default_value="active",
- label="Hide playergroup members in UI",
- description="Hide the individual player entry for the members of this group "
- "in the user interface.",
- advanced=True,
- ),
- )
+ entries = entries + (CONF_ENTRY_HIDE_GROUP_MEMBERS,)
return entries
def on_player_config_changed(
str(kwargs),
)
cmd_result: list[str] = handler(player_id, *args, **kwargs)
+ if asyncio.iscoroutine(cmd_result):
+ cmd_result = await cmd_result
+
if isinstance(cmd_result, dict):
result_parts = dict_to_strings(cmd_result)
result_str = " ".join(urllib.parse.quote(x) for x in result_parts)
response += "\n"
writer.write(response.encode("utf-8"))
await writer.drain()
+ except ConnectionResetError:
+ pass
except Exception as err:
self.logger.debug("Error handling CLI command", exc_info=err)
finally:
str(kwargs),
)
cmd_result = handler(player_id, *args, **kwargs)
+ if asyncio.iscoroutine(cmd_result):
+ cmd_result = await cmd_result
+
if cmd_result is None:
cmd_result = {}
elif not isinstance(cmd_result, dict):
players.append(player_item_from_mass(start_index + index, mass_player))
return PlayersResponse(count=len(players), players_loop=players)
- def _handle_status(
+ async def _handle_status(
self,
player_id: str,
*args,
assert queue is not None
if start_index == "-":
start_index = queue.current_index or 0
- queue_items = self.mass.players.queues.items(queue.queue_id)[
- start_index : start_index + limit
- ]
+ queue_items = []
+ index = 0
+ async for item in self.mass.players.queues.items(queue.queue_id):
+ if index >= start_index:
+ queue_items.append(item)
+ if len(queue_items) == limit:
+ break
+ index += 1
# we ignore the tags, just always send all info
return player_status_from_mass(
self.mass, player=player, queue=queue, queue_items=queue_items
--- /dev/null
+"""
+Universal Group Player provider.
+
+This is more like a "virtual" player provider,
+allowing the user to create player groups from all players known in the system.
+"""
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING
+
+from music_assistant.common.models.config_entries import (
+ CONF_ENTRY_FLOW_MODE,
+ CONF_ENTRY_GROUPED_POWER_ON,
+ CONF_ENTRY_HIDE_GROUP_MEMBERS,
+ CONF_ENTRY_OUTPUT_CHANNELS,
+ ConfigEntry,
+ ConfigValueOption,
+ ConfigValueType,
+)
+from music_assistant.common.models.enums import (
+ ConfigEntryType,
+ PlayerFeature,
+ PlayerState,
+ PlayerType,
+)
+from music_assistant.common.models.player import DeviceInfo, Player
+from music_assistant.common.models.queue_item import QueueItem
+from music_assistant.constants import CONF_GROUPED_POWER_ON
+from music_assistant.server.models.player_provider import PlayerProvider
+
+if TYPE_CHECKING:
+ from music_assistant.common.models.config_entries import ProviderConfig
+ from music_assistant.common.models.provider import ProviderManifest
+ from music_assistant.server import MusicAssistant
+ from music_assistant.server.models import ProviderInstanceType
+
+
+CONF_GROUP_MEMBERS = "group_members"
+
+CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO = ConfigEntry.from_dict(
+ {
+ **CONF_ENTRY_OUTPUT_CHANNELS.to_dict(),
+ "hidden": True,
+ "default_value": "stereo",
+ "value": "stereo",
+ }
+)
+CONF_ENTRY_FORCED_FLOW_MODE = ConfigEntry.from_dict(
+ {**CONF_ENTRY_FLOW_MODE.to_dict(), "hidden": True, "default_value": True, "value": True}
+)
+# ruff: noqa: ARG002
+
+
+async def setup(
+ mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+ """Initialize provider(instance) with given configuration."""
+ prov = UniversalGroupProvider(mass, manifest, config)
+ await prov.handle_setup()
+ return prov
+
+
+async def get_config_entries(
+ mass: MusicAssistant,
+ instance_id: str | None = None,
+ action: str | None = None,
+ values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+ """
+ Return Config entries to setup this provider.
+
+ instance_id: id of an existing provider instance (None if new instance setup).
+ action: [optional] action key called from config entries UI.
+ values: the (intermediate) raw values for config entries sent with the action.
+ """
+ # ruff: noqa: ARG001
+ all_players = tuple(
+ ConfigValueOption(x.display_name, x.player_id)
+ for x in mass.players.all(True, True, False)
+ if x.player_id != instance_id
+ )
+ return (
+ ConfigEntry(
+ key=CONF_GROUP_MEMBERS,
+ type=ConfigEntryType.STRING,
+ label="Group members",
+ default_value=[],
+ options=all_players,
+ description="Select all players you want to be part of this group",
+ multi_value=True,
+ ),
+ )
+
+
+class UniversalGroupProvider(PlayerProvider):
+ """Base/builtin provider for universally grouping players."""
+
+ prev_sync_leaders: tuple[str] | None = None
+
+ async def handle_setup(self) -> None:
+ """Handle async initialization of the provider."""
+ self.player = Player(
+ player_id=self.instance_id,
+ provider=self.domain,
+ type=PlayerType.GROUP,
+ name=self.name,
+ available=True,
+ powered=False,
+ device_info=DeviceInfo(model=self.manifest.name, manufacturer="Music Assistant"),
+ # TODO: derive playerfeatures from (all) underlying child players
+ supported_features=(
+ PlayerFeature.POWER,
+ PlayerFeature.PAUSE,
+ PlayerFeature.VOLUME_SET,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.SET_MEMBERS,
+ ),
+ active_source=self.instance_id,
+ group_childs=self.config.get_value(CONF_GROUP_MEMBERS),
+ )
+ self.mass.players.register_or_update(self.player)
+
+ async def unload(self) -> None:
+ """Handle close/cleanup of the provider."""
+ self.mass.players.remove(self.instance_id)
+
+ def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: # noqa: ARG002
+ """Return all (provider/player specific) Config Entries for the given player (if any)."""
+ return (
+ CONF_ENTRY_HIDE_GROUP_MEMBERS,
+ CONF_ENTRY_GROUPED_POWER_ON,
+ CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO,
+ CONF_ENTRY_FORCED_FLOW_MODE,
+ )
+
+ async def cmd_stop(self, player_id: str) -> None:
+ """Send STOP command to given player."""
+ # forward command to player and any connected sync child's
+ async with asyncio.TaskGroup() as tg:
+ for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+ if member.state == PlayerState.IDLE:
+ continue
+ tg.create_task(self.mass.players.cmd_stop(member.player_id))
+
+ async def cmd_play(self, player_id: str) -> None:
+ """Send PLAY command to given player."""
+ async with asyncio.TaskGroup() as tg:
+ for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+ tg.create_task(self.mass.players.cmd_play(member.player_id))
+
+ async def cmd_play_media(
+ self,
+ player_id: str,
+ queue_item: QueueItem,
+ seek_position: int = 0,
+ fade_in: bool = False,
+ flow_mode: bool = False,
+ ) -> None:
+ """Send PLAY MEDIA command to given player.
+
+ This is called when the Queue wants the player to start playing a specific QueueItem.
+ The player implementation can decide how to process the request, such as playing
+ queue items one-by-one or enqueue all/some items.
+
+ - player_id: player_id of the player to handle the command.
+ - queue_item: the QueueItem to start playing on the player.
+ - seek_position: start playing from this specific position.
+ - fade_in: fade in the music at start (e.g. at resume).
+ """
+ # send stop first
+ await self.cmd_stop(player_id)
+ # power ON
+ await self.cmd_power(player_id, True)
+ # issue sync command (just in case)
+ await self._sync_players()
+ # forward command to all (powered) group child's
+ async with asyncio.TaskGroup() as tg:
+ for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+ player_prov = self.mass.players.get_player_provider(member.player_id)
+ tg.create_task(
+ player_prov.cmd_play_media(
+ member.player_id,
+ queue_item=queue_item,
+ seek_position=seek_position,
+ fade_in=fade_in,
+ flow_mode=flow_mode,
+ )
+ )
+
+ async def cmd_pause(self, player_id: str) -> None:
+ """Send PAUSE command to given player."""
+ async with asyncio.TaskGroup() as tg:
+ for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+ tg.create_task(self.mass.players.cmd_pause(member.player_id))
+
+ async def cmd_power(self, player_id: str, powered: bool) -> None:
+ """Send POWER command to given player."""
+ if self.player.powered == powered:
+ return # nothing to do
+ group_power_on = self.mass.config.get_player_config_value(player_id, CONF_GROUPED_POWER_ON)
+ if powered and not group_power_on:
+ return # nothing to do
+
+ async def set_child_power(child_player: Player) -> None:
+ await self.mass.players.cmd_power(child_player.player_id, powered)
+ # set optimistic state on child player to prevent race conditions in other actions
+ child_player.powered = powered
+
+ async with asyncio.TaskGroup() as tg:
+ for member in self._get_active_members(
+ only_powered=not powered, skip_sync_childs=False
+ ):
+ tg.create_task(set_child_power(member))
+
+ self.player.powered = powered
+ self.mass.players.update(self.instance_id)
+ if powered:
+ # sync all players on power on
+ await self._sync_players()
+
+ async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+ """Send VOLUME_SET command to given player."""
+
+ async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+ """Send VOLUME MUTE command to given player."""
+
+ async def poll_player(self, player_id: str) -> None:
+ """Poll player for state updates."""
+ self.update_attributes()
+ self.mass.players.update(player_id, skip_forward=True)
+
+ def update_attributes(self) -> None:
+ """Update player attributes."""
+ all_members = self._get_active_members(only_powered=False, skip_sync_childs=False)
+ self.player.group_childs = list(x.player_id for x in all_members)
+ # read the state from the first powered child player
+ for member in all_members:
+ if member.synced_to:
+ continue
+ if not member.powered:
+ continue
+ if member.state not in (PlayerState.PLAYING, PlayerState.PAUSED):
+ continue
+ self.player.current_item_id = member.current_item_id
+ self.player.current_url = member.current_url
+ self.player.elapsed_time = member.elapsed_time
+ self.player.elapsed_time_last_updated = member.elapsed_time_last_updated
+ self.player.state = member.state
+ break
+ else:
+ self.player.state = PlayerState.IDLE
+ self.player.current_item_id = None
+ self.player.current_url = None
+
+ def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None:
+ """Call when the state of a child player updates."""
+ # TODO: handle a sync leader powerin off
+ powered_players = self._get_active_members(True, False)
+ if "powered" in changed_keys:
+ if child_player.powered and self.player.state == PlayerState.PLAYING:
+ # a child player turned ON while the group player is already playing
+ # we need to resync/resume
+ self.mass.create_task(self.mass.players.queues.resume, player_id)
+ elif not child_player.powered and len(powered_players) == 0:
+ # the last player of a group turned off
+ # turn off the group
+ self.mass.create_task(self.cmd_power, player_id, False)
+ self.update_attributes()
+ self.mass.players.update(player_id, skip_forward=True)
+
+ def _get_active_members(
+ self, only_powered: bool = False, skip_sync_childs: bool = True
+ ) -> list[Player]:
+ """Get (child) players attached to a grouped player."""
+ child_players: list[Player] = []
+ conf_members: list[str] = self.config.get_value(CONF_GROUP_MEMBERS)
+ ignore_ids = set()
+ for child_id in conf_members:
+ if child_player := self.mass.players.get(child_id, False):
+ if not (not only_powered or child_player.powered):
+ continue
+ if child_player.synced_to and skip_sync_childs:
+ continue
+ allowed_sources = [child_player.player_id, self.instance_id] + conf_members
+ if child_player.active_source not in allowed_sources:
+ # edge case: the child player has another group already active!
+ continue
+ if child_player.synced_to and child_player.synced_to not in conf_members:
+ # edge case: the child player is already synced to another player
+ continue
+ child_players.append(child_player)
+ # handle edge case where a group is in the group and both the group
+ # and (one of its) child's are added to this universal group.
+ if child_player.type == PlayerType.GROUP:
+ ignore_ids.update(
+ x for x in child_player.group_childs if x != child_player.player_id
+ )
+ return [x for x in child_players if x.player_id not in ignore_ids]
+
+ async def _sync_players(self) -> None:
+ """Sync all (possible) players."""
+ sync_leaders = set()
+ # TODO: sort members on sync master priority attribute ?
+ for member in self._get_active_members(only_powered=True):
+ if member.synced_to is not None:
+ continue
+ if not member.can_sync_with:
+ continue
+ # check if we can join this player to an already chosen sync leader
+ if existing_leader := next(
+ (x for x in member.can_sync_with if x in sync_leaders), None
+ ):
+ await self.mass.players.cmd_sync(member.player_id, existing_leader)
+ # set optimistic state to prevent race condition in play media
+ member.synced_to = existing_leader
+ continue
+ # pick this member as new sync leader
+ sync_leaders.add(member.player_id)
+ self.prev_sync_leaders = tuple(sync_leaders)
--- /dev/null
+{
+ "type": "player",
+ "domain": "universal_group",
+ "name": "Universal Group Player",
+ "description": "Create a Player Group with your favorite players, regardless of type and model.",
+ "codeowners": ["@music-assistant"],
+ "requirements": [],
+ "documentation": "",
+ "multi_instance": true,
+ "builtin": false,
+ "load_by_default": false,
+ "icon": "mdi:mdi-speaker-multiple"
+}