/
/
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 PlaybackState, PlayerFeature
18
19from music_assistant.constants import EXTERNAL_SOURCES
20from music_assistant.models.player import DeviceInfo, Player
21
22from .constants import EXTERNAL_SOURCE_PROTOCOLS
23
24if TYPE_CHECKING:
25 from music_assistant_models.player import PlayerMedia, PlayerSource
26
27 from .provider import UniversalPlayerProvider
28
29
30# Volume and mute are excluded: the base Player resolves those to the protocol player.
31FORWARDED_FEATURES = {
32 PlayerFeature.PAUSE,
33 PlayerFeature.SEEK,
34 PlayerFeature.NEXT_PREVIOUS,
35}
36
37
38class UniversalPlayer(Player):
39 """
40 Universal Player implementation.
41
42 A virtual player for devices without native Music Assistant support that use
43 generic streaming protocols. It does NOT have PLAY_MEDIA capability on its own.
44 Playback is always delegated to one of the linked protocol players via the protocol
45 linking system.
46 """
47
48 def __init__(
49 self,
50 provider: UniversalPlayerProvider,
51 player_id: str,
52 name: str,
53 device_info: DeviceInfo,
54 protocol_player_ids: list[str],
55 ) -> None:
56 """
57 Initialize UniversalPlayer instance.
58
59 :param provider: The UniversalPlayerProvider instance.
60 :param player_id: Unique player ID (typically based on MAC address).
61 :param name: Display name for the player.
62 :param device_info: Device information aggregated from protocol players.
63 :param protocol_player_ids: List of protocol player IDs to link.
64 """
65 self._protocol_player_ids = protocol_player_ids
66 super().__init__(provider, player_id)
67 # Set player attributes
68 self._attr_name = name
69 self._attr_device_info = device_info
70 # a universal player does not have any features on its own,
71 # it delegates to protocol players
72 self._attr_supported_features = set()
73
74 @property
75 def available(self) -> bool:
76 """Return if the player is currently available."""
77 return any(
78 (p := self.mass.players.get_player(pid)) and p.available_for_playback
79 for pid in self._protocol_player_ids
80 )
81
82 @property
83 def needs_setup(self) -> bool:
84 """Return if the player needs setup (a protocol is connected but not set up)."""
85 if self.available:
86 return False
87 return self._get_protocol_player_needing_setup() is not None
88
89 @property
90 def setup_reason(self) -> str | None:
91 """Return why the player needs setup, or None when it is ready to use."""
92 if self.available:
93 return None
94 if protocol_player := self._get_protocol_player_needing_setup():
95 return protocol_player.setup_reason
96 return None
97
98 @property
99 def supported_features(self) -> set[PlayerFeature]:
100 """Return the supported features of the player."""
101 if ext_player := self._get_protocol_player_with_external_source():
102 return ext_player.supported_features & FORWARDED_FEATURES
103 return self._attr_supported_features
104
105 @property
106 def active_source(self) -> str | None:
107 """Return the active source of the player."""
108 if ext_player := self._get_protocol_player_with_external_source():
109 return ext_player.active_source
110 return None
111
112 @property
113 def playback_state(self) -> PlaybackState:
114 """Return the current playback state of the player."""
115 if ext_player := self._get_protocol_player_with_external_source():
116 return ext_player.playback_state
117 return self._attr_playback_state
118
119 @property
120 def elapsed_time(self) -> float | None:
121 """Return the elapsed time in (fractional) seconds of the current track."""
122 if ext_player := self._get_protocol_player_with_external_source():
123 return ext_player.elapsed_time
124 return None
125
126 @property
127 def elapsed_time_last_updated(self) -> float | None:
128 """Return when the elapsed time was last updated."""
129 if ext_player := self._get_protocol_player_with_external_source():
130 return ext_player.elapsed_time_last_updated
131 return None
132
133 @property
134 def current_media(self) -> PlayerMedia | None:
135 """Return the current media being played by the player."""
136 if ext_player := self._get_protocol_player_with_external_source():
137 return ext_player.current_media
138 if protocol_player := self._get_active_output_protocol_player():
139 # while playing through an output protocol player, surface its raw current_media
140 # so consumers of this player's raw value (e.g. a sync group mirroring its leader)
141 # can resolve the active queue item. Reading the protocol player's .state here would
142 # route back through this player's __final_current_media and lose the queue item id.
143 return protocol_player.current_media
144 return None
145
146 @property
147 def source_list(self) -> list[PlayerSource]:
148 """Return list of available sources for this player."""
149 if ext_player := self._get_protocol_player_with_external_source():
150 # if an external source is active, show sources from that protocol player
151 return ext_player.source_list
152 return super().source_list
153
154 async def stop(self) -> None:
155 """Handle STOP command on the player."""
156 if ext_player := self._get_protocol_player_with_external_source():
157 await ext_player.stop()
158
159 async def play(self) -> None:
160 """Handle PLAY command on the player."""
161 if ext_player := self._get_protocol_player_with_external_source():
162 await ext_player.play()
163
164 async def pause(self) -> None:
165 """Handle PAUSE command on the player."""
166 if ext_player := self._get_protocol_player_with_external_source():
167 await ext_player.pause()
168
169 async def next_track(self) -> None:
170 """Handle NEXT_TRACK command on the player."""
171 if ext_player := self._get_protocol_player_with_external_source():
172 await ext_player.next_track()
173
174 async def previous_track(self) -> None:
175 """Handle PREVIOUS_TRACK command on the player."""
176 if ext_player := self._get_protocol_player_with_external_source():
177 await ext_player.previous_track()
178
179 async def seek(self, position: int) -> None:
180 """Handle SEEK command on the player."""
181 if ext_player := self._get_protocol_player_with_external_source():
182 await ext_player.seek(position)
183 self.mass.players.trigger_player_update(ext_player.player_id, debounce_delay=2)
184
185 def add_protocol_player(self, protocol_player_id: str) -> None:
186 """Add a protocol player to this universal player."""
187 if protocol_player_id not in self._protocol_player_ids:
188 self._protocol_player_ids.append(protocol_player_id)
189
190 def remove_protocol_player(self, protocol_player_id: str) -> None:
191 """Remove a protocol player from this universal player."""
192 if protocol_player_id in self._protocol_player_ids:
193 self._protocol_player_ids.remove(protocol_player_id)
194
195 def _get_protocol_player_needing_setup(self) -> Player | None:
196 """Return the first connected protocol player that still needs setup, if any."""
197 for pid in self._protocol_player_ids:
198 protocol_player = self.mass.players.get_player(pid)
199 if protocol_player and protocol_player.available and protocol_player.needs_setup:
200 return protocol_player
201 return None
202
203 def _get_active_output_protocol_player(self) -> Player | None:
204 """Return the protocol player currently selected as this player's output, if any."""
205 if self.active_output_protocol and self.active_output_protocol != "native":
206 return self.mass.players.get_player(self.active_output_protocol)
207 return None
208
209 def _get_protocol_player_with_external_source(self) -> Player | None:
210 """
211 Return a chromecast or dlna protocol player that has an external source active.
212
213 Prefers chromecast over dlna because dlna metadata tends to be less reliable.
214 """
215 if self.active_output_protocol:
216 # if an output protocol is active, don't consider external sources
217 return None
218 result: Player | None = None
219 for pid in self._protocol_player_ids:
220 protocol_player = self.mass.players.get_player(pid)
221 if not protocol_player or not protocol_player.available:
222 continue
223 if protocol_player.provider.domain not in EXTERNAL_SOURCE_PROTOCOLS:
224 continue
225 if (
226 protocol_player.active_source
227 and protocol_player.active_source.lower() in EXTERNAL_SOURCES
228 and protocol_player.playback_state != PlaybackState.IDLE
229 ):
230 # chromecast is preferred, return immediately
231 if protocol_player.provider.domain == "chromecast":
232 return protocol_player
233 result = result or protocol_player
234 return result
235