/
/
/
1"""
2Universal Player implementation.
3
4A virtual player for devices that have no native (vendor-specific) provider in
5Music Assistant but support one or more generic streaming protocols such as
6AirPlay, Sendspin, Chromecast, or DLNA.
7
8The Universal Player is automatically created when a protocol player with
9PlayerType.PROTOCOL is registered, providing a unified interface while delegating
10actual playback to the underlying protocol player(s).
11"""
12
13from __future__ import annotations
14
15from typing import TYPE_CHECKING
16
17from music_assistant_models.enums import PlayerFeature
18
19from music_assistant.constants import CONF_PREFERRED_OUTPUT_PROTOCOL
20from music_assistant.models.player import DeviceInfo, Player
21
22if TYPE_CHECKING:
23 from .provider import UniversalPlayerProvider
24
25
26class UniversalPlayer(Player):
27 """
28 Universal Player implementation.
29
30 A virtual player for devices without native Music Assistant support that use
31 generic streaming protocols. It does NOT have PLAY_MEDIA capability on its own.
32 Playback is always delegated to one of the linked protocol players via the protocol
33 linking system.
34 """
35
36 def __init__(
37 self,
38 provider: UniversalPlayerProvider,
39 player_id: str,
40 name: str,
41 device_info: DeviceInfo,
42 protocol_player_ids: list[str],
43 ) -> None:
44 """
45 Initialize UniversalPlayer instance.
46
47 :param provider: The UniversalPlayerProvider instance.
48 :param player_id: Unique player ID (typically based on MAC address).
49 :param name: Display name for the player.
50 :param device_info: Device information aggregated from protocol players.
51 :param protocol_player_ids: List of protocol player IDs to link.
52 """
53 super().__init__(provider, player_id)
54 self._protocol_player_ids = protocol_player_ids
55 # Set player attributes
56 self._attr_name = name
57 self._attr_device_info = device_info
58 # Start as unavailable - will be updated when protocol players are linked
59 self._attr_available = False
60 # a universal player does not have any features on its own,
61 # it delegates to protocol players
62 self._attr_supported_features = set()
63
64 @property
65 def hidden_by_default(self) -> bool:
66 """Return if the player should be hidden in the UI by default."""
67 if len(self.linked_output_protocols) == 0:
68 # If we have no linked protocols, hide by default
69 return True
70 if self.device_info.model.lower() == "web browser": # noqa: SIM103
71 # hide web players by default
72 return True
73 return False
74
75 @property
76 def expose_to_ha_by_default(self) -> bool:
77 """Return if the player should be exposed to Home Assistant by default."""
78 if len(self.linked_output_protocols) == 0:
79 # If we have no linked protocols, hide by default
80 return False
81 if self.device_info.model.lower() == "web browser": # noqa: SIM103
82 # hide web players by default
83 return False
84 return True
85
86 def _get_control_target(
87 self, required_feature: PlayerFeature, require_active: bool = False
88 ) -> Player | None:
89 """Get the best player to send control commands to.
90
91 Prefers the active output protocol, otherwise uses the first available
92 protocol player that supports the needed feature.
93 """
94 # If we have an active protocol, use that
95 if (
96 self.active_output_protocol
97 and self.active_output_protocol != "native"
98 and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
99 and required_feature in protocol_player.supported_features
100 ):
101 return protocol_player
102
103 # If require_active is set, and no active protocol found, return None
104 if require_active:
105 return None
106
107 # Otherwise, use the first available linked protocol
108 for protocol_player_id in self._protocol_player_ids:
109 if (
110 (protocol_player := self.mass.players.get_player(protocol_player_id))
111 and protocol_player.available
112 and required_feature in protocol_player.supported_features
113 ):
114 return protocol_player
115
116 return None
117
118 def update_from_protocol_players(self) -> None:
119 """
120 Update state from linked protocol players.
121
122 Called to sync state like volume, availability from protocol players.
123 """
124 # Aggregate availability - available if any protocol is available
125 self._attr_available = any(
126 (p := self.mass.players.get_player(pid)) and p.available
127 for pid in self._protocol_player_ids
128 )
129 # Get volume from best control target
130 if target := self._get_control_target(PlayerFeature.VOLUME_SET):
131 if target.volume_level is not None:
132 self._attr_volume_level = target.volume_level
133 if target := self._get_control_target(PlayerFeature.VOLUME_MUTE):
134 if target.volume_muted is not None:
135 self._attr_volume_muted = target.volume_muted
136
137 self.update_state()
138
139 def add_protocol_player(self, protocol_player_id: str) -> None:
140 """Add a protocol player to this universal player."""
141 if protocol_player_id not in self._protocol_player_ids:
142 self._protocol_player_ids.append(protocol_player_id)
143
144 def remove_protocol_player(self, protocol_player_id: str) -> None:
145 """Remove a protocol player from this universal player."""
146 if protocol_player_id in self._protocol_player_ids:
147 self._protocol_player_ids.remove(protocol_player_id)
148
149 def _get_preferred_protocol_player(self) -> Player | None:
150 """
151 Get the preferred protocol player for this universal player.
152
153 Selection priority:
154 1. Active output protocol (if set and available)
155 2. User's preferred output protocol (from settings), fallback to highest
156 priority if preferred is not available
157 """
158 # 1. Active output protocol takes precedence
159 if (
160 self.active_output_protocol
161 and self.active_output_protocol != "native"
162 and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
163 and protocol_player.available
164 ):
165 return protocol_player
166
167 # 2. User's preferred output protocol (with fallback to highest priority)
168 preferred = self.mass.config.get_raw_player_config_value(
169 self.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
170 )
171 if preferred and (protocol_player := self.mass.players.get_player(str(preferred))):
172 if protocol_player.available:
173 return protocol_player
174
175 # Fallback: if user's preferred protocol is not available,
176 # use the highest priority available protocol
177 for protocol in sorted(self.linked_output_protocols, key=lambda x: x.priority):
178 if protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
179 if protocol_player.available:
180 return protocol_player
181
182 return None
183