/
/
/
1"""
2Base class/model for a Player within Music Assistant.
3
4All providerspecific players should inherit from this class and implement the required methods.
5
6Note that this is NOT the final state of the player,
7as it may be overridden by (sync)group memberships, configuration options, or other factors.
8This final state will be calculated and snapshotted in the PlayerState dataclass,
9which is what is also what is sent over the API.
10The final active source can be retrieved by using the 'state' property.
11"""
12
13from __future__ import annotations
14
15import asyncio
16import builtins
17import time
18from abc import ABC
19from collections.abc import Callable
20from typing import TYPE_CHECKING, Any, TypeVar, cast, final, overload
21
22from music_assistant_models.config_entries import MULTI_VALUE_SPLITTER, ConfigValueType
23from music_assistant_models.constants import (
24 EXTRA_ATTRIBUTES_TYPES,
25 PLAYER_CONTROL_FAKE,
26 PLAYER_CONTROL_NATIVE,
27 PLAYER_CONTROL_NONE,
28)
29from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
30from music_assistant_models.errors import ActionUnavailable, UnsupportedFeaturedException
31from music_assistant_models.player import (
32 DeviceInfo,
33 OutputProtocol,
34 PlayerMedia,
35 PlayerOption,
36 PlayerOptionValueType,
37 PlayerSoundMode,
38 PlayerSource,
39)
40from music_assistant_models.player import Player as PlayerState
41from music_assistant_models.unique_list import UniqueList
42from propcache import under_cached_property as cached_property
43
44from music_assistant.constants import (
45 ACTIVE_PROTOCOL_FEATURES,
46 ATTR_FAKE_MUTE,
47 ATTR_FAKE_POWER,
48 ATTR_FAKE_VOLUME,
49 CONF_ENTRY_PLAYER_ICON,
50 CONF_EXPOSE_PLAYER_TO_HA,
51 CONF_FLOW_MODE,
52 CONF_HIDE_IN_UI,
53 CONF_LINKED_PROTOCOL_IDS,
54 CONF_MUTE_CONTROL,
55 CONF_PLAYERS,
56 CONF_POWER_CONTROL,
57 CONF_PREFERRED_OUTPUT_PROTOCOL,
58 CONF_SAMPLE_RATES,
59 CONF_UNDERLYING_PLAYER_ID,
60 CONF_VOLUME_CONTROL,
61 EXTERNAL_SOURCES,
62 PLAYER_CONTROL_PROTOCOL,
63 PROTOCOL_FEATURES,
64 PROTOCOL_PRIORITY,
65)
66from music_assistant.helpers.util import html_to_markdown
67
68if TYPE_CHECKING:
69 from music_assistant_models.config_entries import (
70 ConfigActionResult,
71 ConfigEntry,
72 PlayerConfig,
73 )
74 from music_assistant_models.media_items import MediaItemPalette
75 from music_assistant_models.player_queue import PlayerQueue
76
77 from .player_provider import PlayerProvider
78 from .setup_flow import SetupSession
79
80# TypeVar for config value type inference
81_ConfigValueT = TypeVar("_ConfigValueT", bound=ConfigValueType)
82
83
84def _clamp_elapsed_time(elapsed_time: float | None) -> float | None:
85 """Return elapsed_time clamped to a non-negative value."""
86 return max(0.0, elapsed_time) if elapsed_time is not None else None
87
88
89def _resolve_position(
90 primary: int | None,
91 primary_last_updated: float | None,
92 fallback: float | None,
93 fallback_last_updated: float | None,
94) -> tuple[int | None, float | None]:
95 """
96 Return the (elapsed_time, elapsed_time_last_updated) pair to report for a media item.
97
98 :param primary: Position reported by the media itself, preferred when set.
99 :param primary_last_updated: Timestamp belonging to the primary position.
100 :param fallback: Position to report when the media has none of its own.
101 :param fallback_last_updated: Timestamp belonging to the fallback position.
102 """
103 # a position is only meaningful together with the timestamp it was taken at,
104 # so both values always come from the same source - never a mix of the two
105 if primary is not None:
106 return primary, primary_last_updated
107 if fallback is not None:
108 return int(fallback), fallback_last_updated
109 return None, None
110
111
112# corrected-position jumps larger than this (in seconds) are treated as a discrete
113# position change (seek/buffer correction) instead of regular playback progression
114POSITION_JUMP_THRESHOLD = 1.0
115
116# Changes to any of these state keys fire the (debounced) _on_player_media_updated
117# callback. The palette is included because it resolves asynchronously (shortly
118# after a track change) and players push it to their device from the callback.
119MEDIA_IDENTITY_KEYS = frozenset(
120 {
121 "current_media",
122 "current_media.uri",
123 "current_media.title",
124 "current_media.source_id",
125 "current_media.queue_item_id",
126 "current_media.image_url",
127 "current_media.duration",
128 "current_media.palette",
129 }
130)
131
132# config-derived cached properties (propcache keys in Player._cache); these are
133# only invalidated by set_config, all other cached properties (including those
134# defined by player implementations) are invalidated on every update_state call
135_CONFIG_CACHED_PROPS = frozenset({"icon", "hide_in_ui", "expose_to_ha"})
136
137
138def _reconcile_position_anchor(
139 prev_position: float | None,
140 prev_timestamp: float | None,
141 new_position: float | None,
142 new_timestamp: float | None,
143 prev_playing: bool,
144 new_playing: bool,
145 force_adopt: bool = False,
146) -> tuple[float | None, float | None, bool]:
147 """
148 Reconcile a playback position anchor (position + timestamp pair) with its predecessor.
149
150 The anchor is a value that only changes on discrete events: regular playback
151 progression extrapolates to (nearly) the same corrected position as the previous
152 anchor and therefore keeps the previous anchor, so steady playback yields no
153 state change at all. The anchor is only adopted when the corrected position
154 jumped more than POSITION_JUMP_THRESHOLD (seek/buffer correction) or when
155 force_adopt is set.
156
157 :param prev_position: Position of the previous anchor.
158 :param prev_timestamp: Timestamp of the previous anchor.
159 :param new_position: Position of the candidate anchor.
160 :param new_timestamp: Timestamp of the candidate anchor.
161 :param prev_playing: Whether the player was playing at the previous anchor.
162 :param new_playing: Whether the player is playing at the candidate anchor.
163 :param force_adopt: Always adopt the candidate anchor (still reports jumps).
164
165 Returns a (position, timestamp, jumped) tuple where jumped indicates a
166 corrected-position discontinuity larger than the threshold.
167 """
168 if (
169 not isinstance(prev_position, int | float)
170 or not isinstance(prev_timestamp, int | float)
171 or not isinstance(new_position, int | float)
172 or not isinstance(new_timestamp, int | float)
173 ):
174 # incomplete (or non-numeric) anchor data: adopt the candidate as-is
175 return new_position, new_timestamp, False
176 now = time.time()
177 # a position anchor only advances (extrapolates) while playing
178 prev_corrected = prev_position + (now - prev_timestamp) if prev_playing else prev_position
179 new_corrected = new_position + (now - new_timestamp) if new_playing else new_position
180 jumped = abs(prev_corrected - new_corrected) > POSITION_JUMP_THRESHOLD
181 if force_adopt or jumped:
182 return new_position, new_timestamp, jumped
183 return prev_position, prev_timestamp, False
184
185
186def _anchor_moved(
187 prev_anchor: tuple[Any, Any] | None,
188 new_anchor: tuple[Any, Any] | None,
189 prev_playing: bool,
190 new_playing: bool,
191) -> bool:
192 """Return whether a position anchor pair moved significantly since its predecessor."""
193 if prev_anchor is None or new_anchor is None:
194 return prev_anchor != new_anchor
195 prev_position, prev_timestamp = prev_anchor
196 new_position, new_timestamp = new_anchor
197 if (
198 not isinstance(prev_position, int | float)
199 or not isinstance(prev_timestamp, int | float)
200 or not isinstance(new_position, int | float)
201 or not isinstance(new_timestamp, int | float)
202 ):
203 # incomplete anchor data can not extrapolate: any change counts as moved
204 return prev_anchor != new_anchor
205 _, _, jumped = _reconcile_position_anchor(
206 prev_position, prev_timestamp, new_position, new_timestamp, prev_playing, new_playing
207 )
208 return jumped
209
210
211def _freeze(value: Any) -> Any:
212 """Return an immutable snapshot of a (serializable) attribute value."""
213 if isinstance(value, dict):
214 return tuple(sorted((key, _freeze(subvalue)) for key, subvalue in value.items()))
215 if isinstance(value, set | frozenset):
216 return frozenset(_freeze(item) for item in value)
217 if isinstance(value, list | tuple):
218 return tuple(_freeze(item) for item in value)
219 return value
220
221
222def _media_fingerprint(fingerprint: dict[str, Any], prefix: str, media: PlayerMedia) -> None:
223 """Add the leaf values of a PlayerMedia to a state fingerprint."""
224 fingerprint[f"{prefix}.uri"] = media.uri
225 fingerprint[f"{prefix}.media_type"] = media.media_type
226 fingerprint[f"{prefix}.title"] = media.title
227 fingerprint[f"{prefix}.artist"] = media.artist
228 fingerprint[f"{prefix}.album"] = media.album
229 fingerprint[f"{prefix}.album_artist"] = media.album_artist
230 fingerprint[f"{prefix}.image_url"] = media.image_url
231 fingerprint[f"{prefix}.duration"] = media.duration
232 fingerprint[f"{prefix}.source_id"] = media.source_id
233 fingerprint[f"{prefix}.queue_item_id"] = media.queue_item_id
234 fingerprint[f"{prefix}.elapsed_time"] = media.elapsed_time
235 fingerprint[f"{prefix}.elapsed_time_last_updated"] = media.elapsed_time_last_updated
236 # the palette object is carried/reused as-is until the image changes,
237 # so object identity suffices to detect a (re)resolved palette
238 fingerprint[f"{prefix}.palette"] = id(media.palette) if media.palette is not None else None
239 fingerprint[f"{prefix}.custom_data"] = (
240 _freeze(media.custom_data) if media.custom_data is not None else None
241 )
242
243
244def _state_fingerprint(state: PlayerState) -> dict[str, Any]:
245 """
246 Collect a flat fingerprint of all event-relevant leaf values of a PlayerState.
247
248 Used to detect changes between state calculations without deepcopying the
249 previous state graph or recursively diffing dataclasses: the fingerprint
250 holds only immutable snapshots, so it stays valid even for values the state
251 references live (extra_attributes, device_info).
252 """
253 fingerprint: dict[str, Any] = {
254 "player_id": state.player_id,
255 "provider": state.provider,
256 "type": state.type,
257 "name": state.name,
258 "available": state.available,
259 "playback_state": state.playback_state,
260 # NOTE: the player's own elapsed_time/elapsed_time_last_updated are
261 # deliberately absent: current_media holds the final calculated position
262 # and is the only position that is event-relevant
263 "powered": state.powered,
264 "volume_level": state.volume_level,
265 "volume_muted": state.volume_muted,
266 "group_members": tuple(state.group_members),
267 "static_group_members": tuple(state.static_group_members),
268 "can_group_with": frozenset(state.can_group_with),
269 "synced_to": state.synced_to,
270 "active_sound_mode": state.active_sound_mode,
271 "active_source": state.active_source,
272 "active_group": state.active_group,
273 "enabled": state.enabled,
274 "hide_in_ui": state.hide_in_ui,
275 "expose_to_ha": state.expose_to_ha,
276 "icon": state.icon,
277 "group_volume": state.group_volume,
278 "group_volume_muted": state.group_volume_muted,
279 "power_control": state.power_control,
280 "volume_control": state.volume_control,
281 "mute_control": state.mute_control,
282 "active_output_protocol": state.active_output_protocol,
283 "needs_setup": state.needs_setup,
284 "setup_reason": state.setup_reason,
285 "has_setup_flow": state.has_setup_flow,
286 "sleep_timer_expires_at": state.sleep_timer_expires_at,
287 "supported_features": frozenset(state.supported_features),
288 "sound_mode_list": tuple((m.id, m.name, m.passive) for m in state.sound_mode_list),
289 "options": tuple((o.key, o.value, o.read_only) for o in state.options),
290 "source_list": tuple(
291 (s.id, s.name, s.passive, s.can_play_pause, s.can_seek, s.can_next_previous)
292 for s in state.source_list
293 ),
294 "output_protocols": tuple(
295 (
296 o.output_protocol_id,
297 o.name,
298 o.protocol_domain,
299 o.is_native,
300 o.priority,
301 o.available,
302 o.derived_from,
303 )
304 for o in state.output_protocols
305 ),
306 "device_info.model": state.device_info.model,
307 "device_info.manufacturer": state.device_info.manufacturer,
308 "device_info.software_version": state.device_info.software_version,
309 "device_info.model_id": state.device_info.model_id,
310 "device_info.manufacturer_id": state.device_info.manufacturer_id,
311 "device_info.identifiers": tuple(sorted(state.device_info.identifiers.items())),
312 "current_media": state.current_media is not None,
313 }
314 for key, value in state.extra_attributes.items():
315 if key in ("seq_no", "last_poll"):
316 # noisy bookkeeping values, not relevant for the state
317 continue
318 fingerprint[f"extra_attributes.{key}"] = _freeze(value)
319 if state.current_media is not None:
320 _media_fingerprint(fingerprint, "current_media", state.current_media)
321 return fingerprint
322
323
324class Player(ABC):
325 """
326 Base representation of a Player within the Music Assistant Server.
327
328 Player Provider implementations should inherit from this base model.
329 """
330
331 _attr_type: PlayerType = PlayerType.PLAYER
332 _attr_supported_features: set[PlayerFeature]
333 _attr_group_members: list[str]
334 _attr_static_group_members: list[str]
335 _attr_device_info: DeviceInfo
336 _attr_can_group_with: set[str]
337 _attr_source_list: list[PlayerSource]
338 _attr_sound_mode_list: list[PlayerSoundMode]
339 _attr_options: list[PlayerOption]
340 _attr_available: bool = True
341 _attr_name: str | None = None
342 _attr_powered: bool | None = None
343 _attr_playback_state: PlaybackState = PlaybackState.IDLE
344 _attr_volume_level: int | None = None
345 _attr_volume_muted: bool | None = None
346 _attr_elapsed_time: float | None = None
347 _attr_elapsed_time_last_updated: float | None = None
348 _attr_active_source: str | None = None
349 _attr_active_sound_mode: str | None = None
350 _attr_current_media: PlayerMedia | None = None
351 # Palette for the image currently shown, resolved asynchronously from the
352 # cache controller and carried here so the (synchronous) state serialization
353 # can read it back without blocking. See set_resolved_palette.
354 _attr_current_palette: MediaItemPalette | None = None
355 _attr_current_palette_url: str | None = None
356 _attr_needs_poll: bool = False
357 _attr_poll_interval: int = 30
358 _attr_hidden_by_default: bool = False
359 _attr_expose_to_ha_by_default: bool = True
360 _attr_enabled_by_default: bool = True
361 _attr_needs_setup: bool = False
362 _attr_setup_reason: str | None = None
363 _attr_supported_sample_rates: list[tuple[int, int]] | None = None
364 _attr_underlying_player_id: str | None = None
365
366 def __init__(self, provider: PlayerProvider, player_id: str) -> None:
367 """Initialize the Player."""
368 # set mass as public variable
369 self.mass = provider.mass
370 self.logger = provider.logger
371 # initialize mutable attributes
372 self._attr_supported_features = set()
373 self._attr_group_members = []
374 self._attr_static_group_members = []
375 self._attr_device_info = DeviceInfo()
376 self._attr_can_group_with = set()
377 self._attr_source_list = []
378 self._attr_sound_mode_list = []
379 self._attr_options = []
380 # do not override/overwrite these private attributes below!
381 self._cache: dict[str, Any] = {} # storage dict for cached properties
382 self.__attr_linked_protocols: list[OutputProtocol] = []
383 self.__attr_protocol_parent_id: str | None = None
384 self.__attr_active_output_protocol: str | None = None
385 self._player_id = player_id
386 self._provider = provider
387 self.mass.config.create_default_player_config(
388 player_id, self.provider_id, self.type, self.name, self.enabled_by_default
389 )
390 self._config = self.mass.config.get_base_player_config(player_id, self.provider_id)
391 self._extra_data: dict[str, Any] = {}
392 self._extra_attributes: dict[str, Any] = {}
393 self._on_unload_callbacks: list[Callable[[], None]] = []
394 self.__active_mass_source: str | None = None
395 self.__initialized = asyncio.Event()
396 # Change-tracking internals for update_state:
397 # - state_dirty forces a recalculation (state derived from other
398 # sources changed) - starts True so the first update always calculates
399 # - input_snapshot/input_anchor hold the player's own inputs at the last
400 # calculation, so a no-change update_state call can return immediately
401 # - state_fingerprint holds the flat leaf values of the last calculated
402 # PlayerState, used to determine the changed values without deepcopy
403 self.__state_dirty: bool = True
404 self.__input_snapshot: dict[str, Any] | None = None
405 self.__input_anchor: tuple[tuple[Any, Any], tuple[Any, Any] | None, bool] | None = None
406 self.__state_fingerprint: dict[str, Any] | None = None
407 # only probe synced_to when a provider implementation overrides it with its
408 # own (cheap) state; the base implementation derives it by scanning sibling
409 # players, which is cross-player state covered by mark_state_dirty instead
410 self.__probe_synced_to: bool = type(self).synced_to is not Player.synced_to
411 # The PlayerState is the (snapshotted) final state of the player
412 # after applying any config overrides and other transformations,
413 # such as the display name and player controls.
414 # the state is updated when calling 'update_state' and is what is sent over the API.
415 self._state = PlayerState(
416 player_id=self.player_id,
417 provider=self.provider_id,
418 type=self.type,
419 name=self.display_name,
420 available=self.available,
421 device_info=self.device_info,
422 supported_features=self.supported_features,
423 playback_state=self.playback_state,
424 )
425
426 @property
427 def available(self) -> bool:
428 """Return if the player is available."""
429 return self._attr_available
430
431 @property
432 def type(self) -> PlayerType:
433 """Return the type of the player."""
434 return self._attr_type
435
436 @property
437 def name(self) -> str | None:
438 """Return the name of the player."""
439 return self._attr_name
440
441 @property
442 def supported_features(self) -> set[PlayerFeature]:
443 """Return the supported features of the player."""
444 return self._attr_supported_features
445
446 @property
447 def playback_state(self) -> PlaybackState:
448 """Return the current playback state of the player."""
449 return self._attr_playback_state
450
451 @property
452 def requires_flow_mode(self) -> bool:
453 """Return if the player needs flow mode for (queue) playback."""
454 # Default implementation: True if the player does not support PlayerFeature.ENQUEUE
455 return PlayerFeature.ENQUEUE not in self.supported_features
456
457 @property
458 def device_info(self) -> DeviceInfo:
459 """Return the device info of the player."""
460 return self._attr_device_info
461
462 @property
463 def elapsed_time(self) -> float | None:
464 """Return the elapsed time in (fractional) seconds of the current track (if any)."""
465 return _clamp_elapsed_time(self._attr_elapsed_time)
466
467 @property
468 def elapsed_time_last_updated(self) -> float | None:
469 """
470 Return when the elapsed time was last updated.
471
472 return: The (UTC) timestamp when the elapsed time was last updated,
473 or None if it was never updated (or unknown).
474 """
475 return self._attr_elapsed_time_last_updated
476
477 @property
478 def needs_poll(self) -> bool:
479 """Return if the player needs to be polled for state updates."""
480 return self._attr_needs_poll
481
482 @property
483 def poll_interval(self) -> int:
484 """
485 Return the (dynamic) poll interval for the player.
486
487 Only used if 'needs_poll' is set to True.
488 This should return the interval in seconds.
489 """
490 return self._attr_poll_interval
491
492 @property
493 def hidden_by_default(self) -> bool:
494 """Return if the player should be hidden in the UI by default."""
495 return self._attr_hidden_by_default
496
497 @property
498 def expose_to_ha_by_default(self) -> bool:
499 """Return if the player should be exposed to Home Assistant by default."""
500 return self._attr_expose_to_ha_by_default
501
502 @property
503 def enabled_by_default(self) -> bool:
504 """Return if the player should be enabled by default."""
505 return self._attr_enabled_by_default
506
507 @property
508 def static_group_members(self) -> list[str]:
509 """
510 Return the static group members for a player group.
511
512 For PlayerType.GROUP return the player_ids of members that must/can not be removed by
513 the user. For all other player types return an empty list.
514 """
515 return self._attr_static_group_members
516
517 @property
518 def needs_setup(self) -> bool:
519 """
520 Return if the player needs setup.
521
522 If True, the player needs some sort of (initial) setup before it can be used,
523 such as completing an authentication flow or providing additional configuration.
524 """
525 return self._attr_needs_setup
526
527 @property
528 def setup_reason(self) -> str | None:
529 """
530 Return a short (translatable) slug describing why the player needs setup.
531
532 Only meaningful while ``needs_setup`` is True; surfaced next to the "Setup
533 required" indicator so the UI can explain what to do (e.g. "pairing_required"
534 or "password_required"). Returns None when there is no specific reason.
535 """
536 return self._attr_setup_reason
537
538 @property
539 @final
540 def available_for_playback(self) -> bool:
541 """
542 Return if the player can currently be used to play (or control) audio.
543
544 A device that is reachable but still needs setup - an unpaired AirPlay
545 receiver, for example - can not accept a stream, so it must never be
546 picked as an output protocol or command target.
547 """
548 return self.available and not self.needs_setup
549
550 @property
551 @final
552 def implements_setup_flow(self) -> bool:
553 """Return if this player implements its own interactive setup flow."""
554 return type(self).run_setup_flow is not Player.run_setup_flow
555
556 @property
557 @final
558 def has_setup_flow(self) -> bool:
559 """
560 Return if an interactive setup flow can be started for this player.
561
562 True when the player implements its own setup flow, or when it wraps a
563 (non-native) protocol child player that does. Unlike ``needs_setup`` this stays
564 True once setup completed, so the UI can offer to re-run the flow on demand
565 (e.g. to redo a pairing step that was skipped).
566 """
567 if self.implements_setup_flow:
568 return True
569 for output_protocol in self.output_protocols:
570 if output_protocol.is_native:
571 continue
572 child = self.mass.players.get_player(output_protocol.output_protocol_id)
573 if child is not None and child.implements_setup_flow:
574 return True
575 return False
576
577 @property
578 def powered(self) -> bool | None:
579 """
580 Return if the player is powered on.
581
582 If the player does not support PlayerFeature.POWER,
583 or the state is (currently) unknown, this property may return None.
584 """
585 return self._attr_powered
586
587 @property
588 def volume_level(self) -> int | None:
589 """
590 Return the current volume level (0..100) of the player.
591
592 If the player does not support PlayerFeature.VOLUME_SET,
593 or the state is (currently) unknown, this property may return None.
594 """
595 return self._attr_volume_level
596
597 @property
598 def volume_muted(self) -> bool | None:
599 """
600 Return the current mute state of the player.
601
602 If the player does not support PlayerFeature.VOLUME_MUTE,
603 or the state is (currently) unknown, this property may return None.
604 """
605 return self._attr_volume_muted
606
607 @property
608 def active_source(self) -> str | None:
609 """
610 Return the (id of) the active source of the player.
611
612 Only required if the player supports PlayerFeature.SELECT_SOURCE.
613
614 Set to None if the player is not currently playing a source or
615 the player_id if the player is currently playing a MA queue.
616 """
617 return self._attr_active_source
618
619 @property
620 def group_members(self) -> list[str]:
621 """
622 Return the group members of the player.
623
624 If there are other players synced/grouped with this player,
625 this should return the id's of players synced to this player,
626 and this should include the player's own id (as first item in the list).
627
628 If there are currently no group members, this should return an empty list.
629 """
630 return self._attr_group_members
631
632 @property
633 def can_group_with(self) -> set[str]:
634 """
635 Return the id's of players this player can group with.
636
637 This should return set of player_id's this player can group/sync with
638 or just the provider's instance_id if all players can group with each other.
639 """
640 return self._attr_can_group_with
641
642 @property
643 def native_grouping_requires_own_stream(self) -> bool:
644 """
645 Return whether native grouping only works while this player renders its own stream.
646
647 Device-side grouping keeps working whatever feeds the leader, so members can
648 stay natively grouped while the leader streams over another protocol.
649 Grouping that attaches members to the leader's own stream has nothing for
650 them to join once the leader moves to a protocol.
651 """
652 return False
653
654 @property
655 def is_active_session(self) -> bool:
656 """
657 Return whether this group player is currently holding (capturing) its members.
658
659 Used by :meth:`__final_active_group` to decide whether children should be
660 considered "owned" by this group at this moment. Non-group players should
661 always return ``False``. Group implementations should return ``True`` while
662 a session is being formed, while it is actively playing/paused, and during
663 any idle grace period; and ``False`` once the group is fully dormant.
664 """
665 return False
666
667 @property
668 def synced_to(self) -> str | None:
669 """Return the id of the player this player is synced to (sync leader)."""
670 # default implementation, feel free to override if your
671 # provider has a more efficient way to determine this
672 if self.type == PlayerType.GROUP:
673 return None
674 for player in self.mass.players.iter_players(
675 return_unavailable=False,
676 provider_filter=self.provider.instance_id,
677 return_protocol_players=True,
678 ):
679 if player.type == PlayerType.GROUP:
680 continue
681 if self.player_id in player.group_members and player.player_id != self.player_id:
682 return player.player_id
683 return None
684
685 @property
686 def current_media(self) -> PlayerMedia | None:
687 """Return the current media being played by the player."""
688 return self._attr_current_media
689
690 @property
691 def source_list(self) -> list[PlayerSource]:
692 """Return list of available (native) sources for this player."""
693 return self._attr_source_list
694
695 @property
696 def active_sound_mode(self) -> str | None:
697 """Return active sound mode of this player."""
698 return self._attr_active_sound_mode
699
700 @cached_property
701 def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
702 """Return available PlayerSoundModes for Player."""
703 return UniqueList(self._attr_sound_mode_list)
704
705 @cached_property
706 def options(self) -> UniqueList[PlayerOption]:
707 """Return all PlayerOptions for Player."""
708 return UniqueList(self._attr_options)
709
710 @property
711 def supported_sample_rates(self) -> list[tuple[int, int]] | None:
712 """
713 Return the (sample_rate, bit_depth) pairs this player natively supports.
714
715 Example: [(44100, 16), (48000, 24)]
716
717 Players with a known static set should set ``_attr_supported_sample_rates``.
718 Players whose supported rates depend on runtime state (e.g. group players
719 whose members can change) should override this property.
720
721 Returning ``None`` defers to the user's per-player ``CONF_SAMPLE_RATES``
722 selection — callers should use ``get_supported_sample_rates()`` to get a
723 resolved, non-None list.
724 """
725 return self._attr_supported_sample_rates
726
727 async def power(self, powered: bool) -> None:
728 """
729 Handle POWER command on the player.
730
731 Will only be called if the PlayerFeature.POWER is supported.
732
733 :param powered: bool if player should be powered on or off.
734 """
735 raise NotImplementedError("power needs to be implemented when PlayerFeature.POWER is set")
736
737 async def volume_set(self, volume_level: int) -> None:
738 """
739 Handle VOLUME_SET command on the player.
740
741 Will only be called if the PlayerFeature.VOLUME_SET is supported.
742
743 :param volume_level: volume level (0..100) to set on the player.
744 """
745 raise NotImplementedError(
746 "volume_set needs to be implemented when PlayerFeature.VOLUME_SET is set"
747 )
748
749 async def volume_mute(self, muted: bool) -> None:
750 """
751 Handle VOLUME MUTE command on the player.
752
753 Will only be called if the PlayerFeature.VOLUME_MUTE is supported.
754
755 :param muted: bool if player should be muted.
756 """
757 raise NotImplementedError(
758 "volume_mute needs to be implemented when PlayerFeature.VOLUME_MUTE is set"
759 )
760
761 async def play(self) -> None:
762 """Handle PLAY command on the player."""
763 raise NotImplementedError("play needs to be implemented")
764
765 async def stop(self) -> None:
766 """
767 Handle STOP command on the player.
768
769 Will be called to stop the stream/playback if the player has play_media support.
770 """
771 raise NotImplementedError(
772 "stop needs to be implemented when PlayerFeature.PLAY_MEDIA is set"
773 )
774
775 async def pause(self) -> None:
776 """
777 Handle PAUSE command on the player.
778
779 Will only be called if the player reports PlayerFeature.PAUSE is supported.
780 """
781 raise NotImplementedError("pause needs to be implemented when PlayerFeature.PAUSE is set")
782
783 async def next_track(self) -> None:
784 """
785 Handle NEXT_TRACK command on the player.
786
787 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
788 is supported and the player's currently selected source supports it.
789 """
790 raise NotImplementedError(
791 "next_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
792 )
793
794 async def previous_track(self) -> None:
795 """
796 Handle PREVIOUS_TRACK command on the player.
797
798 Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
799 is supported and the player's currently selected source supports it.
800 """
801 raise NotImplementedError(
802 "previous_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
803 )
804
805 async def seek(self, position: int) -> None:
806 """
807 Handle SEEK command on the player.
808
809 Seek to a specific position in the current track.
810 Will only be called if the player reports PlayerFeature.SEEK is
811 supported and the player is NOT currently playing a MA queue.
812
813 :param position: The position to seek to, in seconds.
814 """
815 raise NotImplementedError("seek needs to be implemented when PlayerFeature.SEEK is set")
816
817 async def play_media(
818 self,
819 media: PlayerMedia,
820 ) -> None:
821 """
822 Handle PLAY MEDIA command on given player.
823
824 This is called by the Player controller to start playing Media on the player,
825 which can be a MA queue item/stream or a native source.
826 The provider's own implementation should work out how to handle this request.
827
828 :param media: Details of the item that needs to be played on the player.
829 """
830 raise NotImplementedError(
831 "play_media needs to be implemented when PlayerFeature.PLAY_MEDIA is set"
832 )
833
834 async def on_protocol_playback(
835 self,
836 output_protocol: OutputProtocol,
837 ) -> None:
838 """
839 Handle callback when playback starts on a protocol output.
840
841 Called by the Player Controller after play_media is executed on a protocol player.
842 Allows the native player implementation to perform special logic when protocol
843 playback starts.
844
845 Optional - providers can override to implement protocol-specific logic.
846
847 :param output_protocol: The OutputProtocol object containing protocol details.
848 """
849 return # Optional callback - no-op by default
850
851 async def enqueue_next_media(self, media: PlayerMedia) -> None:
852 """
853 Handle enqueuing of the next (queue) item on the player.
854
855 Called when player reports it started buffering a queue item
856 and when the queue items updated.
857
858 A PlayerProvider implementation is in itself responsible for handling this
859 so that the queue items keep playing until its empty or the player stopped.
860
861 Will only be called if the player reports PlayerFeature.ENQUEUE is
862 supported and the player is currently playing a MA queue.
863
864 This will NOT be called if the end of the queue is reached (and repeat disabled).
865 This will NOT be called if the player is using flow mode to playback the queue.
866
867 :param media: Details of the item that needs to be enqueued on the player.
868 """
869 raise NotImplementedError(
870 "enqueue_next_media needs to be implemented when PlayerFeature.ENQUEUE is set"
871 )
872
873 async def play_announcement(
874 self, announcement: PlayerMedia, volume_level: int | None = None
875 ) -> None:
876 """
877 Handle (native) playback of an announcement on the player.
878
879 Will only be called if the PlayerFeature.PLAY_ANNOUNCEMENT is supported.
880
881 :param announcement: Details of the announcement that needs to be played on the player.
882 :param volume_level: The volume level to play the announcement at (0..100).
883 If not set, the player should use the current volume level.
884 """
885 raise NotImplementedError(
886 "play_announcement needs to be implemented when PlayerFeature.PLAY_ANNOUNCEMENT is set"
887 )
888
889 async def select_source(self, source: str) -> None:
890 """
891 Handle SELECT SOURCE command on the player.
892
893 Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
894
895 :param source: The source(id) to select, as defined in the source_list.
896 """
897 raise NotImplementedError(
898 "select_source needs to be implemented when PlayerFeature.SELECT_SOURCE is set"
899 )
900
901 async def select_sound_mode(self, sound_mode: str) -> None:
902 """
903 Handle SELECT SOUND MODE command on the player.
904
905 Will only be called if the PlayerFeature.SELECT_SOUND_MODE is supported.
906
907 :param source: The sound_mode(id) to select, as defined in the sound_mode_list.
908 """
909 raise NotImplementedError(
910 "select_sound_mode needs to be implemented when PlayerFeature.SELECT_SOUND_MODE is set"
911 )
912
913 async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
914 """
915 Handle SET_OPTION command on the player.
916
917 Will only be called if the PlayerFeature.OPTIONS is supported.
918
919 :param option_key: The option_key of the PlayerOption
920 :param option_value: The new value of the PlayerOption
921 """
922 raise NotImplementedError(
923 "set_option needs to be implemented when PlayerFeature.Option is set"
924 )
925
926 async def set_members(
927 self,
928 player_ids_to_add: list[str] | None = None,
929 player_ids_to_remove: list[str] | None = None,
930 ) -> None:
931 """
932 Handle SET_MEMBERS command on the player.
933
934 Group or ungroup the given child player(s) to/from this player.
935 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
936
937 :param player_ids_to_add: List of player_id's to add to the group.
938 :param player_ids_to_remove: List of player_id's to remove from the group.
939 """
940 raise NotImplementedError(
941 "set_members needs to be implemented when PlayerFeature.SET_MEMBERS is set"
942 )
943
944 async def poll(self) -> None:
945 """
946 Poll player for state updates.
947
948 This is called by the Player Manager;
949 if the 'needs_poll' property is True.
950 """
951 raise NotImplementedError("poll needs to be implemented when needs_poll is True")
952
953 async def get_config_entries(self) -> list[ConfigEntry]:
954 """
955 Return all (provider/player specific) Config Entries for the player.
956
957 Called only for an existing player: read current values via ``self.config``/
958 ``self.get_config_value`` and capabilities via ``self.supported_features``.
959 To override a default config entry, define an entry with the same key.
960 Include ``ConfigEntryType.ACTION`` entries for one-shot buttons and handle their
961 presses in ``handle_config_action``.
962 """
963 return []
964
965 async def handle_config_action(
966 self, action: str
967 ) -> list[ConfigEntry] | ConfigActionResult | None:
968 """
969 Run the one-shot side effect for a pressed action button from this player's config.
970
971 Override to run the side effect for each ``ConfigEntryType.ACTION`` entry this
972 player declares. Return a ``ConfigActionResult`` to report the outcome (a message
973 to show and/or a url to open), or None when there is nothing to report. Raise to
974 report failure to the caller. Returning entries re-renders the config form from the
975 owning player's freshly resolved entries; the returned entries themselves are not
976 shown, so they serve only as the signal that a re-render is needed.
977
978 :param action: The action id of the pressed button (an entry's ``action`` key).
979 """
980 raise ActionUnavailable(f"Unknown action: {action}")
981
982 async def run_setup_flow(self, session: SetupSession) -> None:
983 """
984 Run the interactive setup flow for this player (e.g. pairing).
985
986 Override in player implementations that require user interaction to become
987 usable; players without an override report that there is nothing to set up.
988
989 :param session: The setup flow session used to interact with the user.
990 """
991 raise NotImplementedError
992
993 @overload
994 def get_config_value(
995 self, key: str, default: _ConfigValueT, *, return_type: builtins.type[_ConfigValueT] = ...
996 ) -> _ConfigValueT: ...
997
998 @overload
999 def get_config_value(
1000 self, key: str, default: ConfigValueType = ..., *, return_type: builtins.type[_ConfigValueT]
1001 ) -> _ConfigValueT: ...
1002
1003 @overload
1004 def get_config_value(
1005 self, key: str, default: ConfigValueType = ..., *, return_type: None = ...
1006 ) -> ConfigValueType: ...
1007
1008 def get_config_value(
1009 self,
1010 key: str,
1011 default: ConfigValueType = None,
1012 *,
1013 return_type: builtins.type[_ConfigValueT | ConfigValueType] | None = None,
1014 ) -> _ConfigValueT | ConfigValueType:
1015 """
1016 Return a single config value from this player's active configuration.
1017
1018 Entry defaults are already applied to the active configuration, so the
1019 default is only returned when the key itself is not present.
1020
1021 :param key: The config key to retrieve.
1022 :param default: Value to return when the key is not present in the config.
1023 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
1024 Note: This parameter is used purely for static type checking and does not
1025 perform runtime type validation. Callers are responsible for ensuring the
1026 specified type matches the actual config value type.
1027 """
1028 return self.config.get_value(key, default)
1029
1030 def get_setup_value(self, key: str, default: ConfigValueType = None) -> ConfigValueType:
1031 """
1032 Return a value collected by this player's setup flow (from setup_data).
1033
1034 Encrypted (string) values are decrypted transparently. Reads setup_data only
1035 (no fallback to the config values): player-owned credentials/pairing data live
1036 exclusively in setup_data, with a one-time migration moving any legacy values.
1037
1038 :param key: The setup data key to retrieve.
1039 :param default: Value to return when the key is not present.
1040 """
1041 setup_data = self.mass.config.get(f"{CONF_PLAYERS}/{self.player_id}/setup_data") or {}
1042 if key in setup_data:
1043 value = setup_data[key]
1044 return self.mass.config.decrypt_string(value) if isinstance(value, str) else value
1045 return default
1046
1047 @final
1048 def resolve_output_player(self) -> Player:
1049 """
1050 Return the player that actually renders this player's audio output.
1051
1052 For a player playing via one of its linked output protocols this is the
1053 active protocol player; in all other cases (native output, protocol
1054 players themselves, group players serving their own stream) it is the
1055 player itself.
1056 """
1057 active_protocol = self.active_output_protocol
1058 if (
1059 active_protocol
1060 and active_protocol != "native"
1061 and (protocol_player := self.mass.players.get_player(active_protocol))
1062 ):
1063 return protocol_player
1064 return self
1065
1066 @overload
1067 def get_output_config_value(
1068 self, key: str, default: _ConfigValueT, *, return_type: builtins.type[_ConfigValueT] = ...
1069 ) -> _ConfigValueT: ...
1070
1071 @overload
1072 def get_output_config_value(
1073 self, key: str, default: ConfigValueType = ..., *, return_type: builtins.type[_ConfigValueT]
1074 ) -> _ConfigValueT: ...
1075
1076 @overload
1077 def get_output_config_value(
1078 self, key: str, default: ConfigValueType = ..., *, return_type: None = ...
1079 ) -> ConfigValueType: ...
1080
1081 def get_output_config_value(
1082 self,
1083 key: str,
1084 default: ConfigValueType = None,
1085 *,
1086 return_type: builtins.type[_ConfigValueT | ConfigValueType] | None = None,
1087 ) -> _ConfigValueT | ConfigValueType:
1088 """
1089 Return a config value resolved on the player that renders the audio output.
1090
1091 Audio/output related settings (output codec, http profile, output channels,
1092 sample rates) live on the player(protocol) that actually renders the audio:
1093 the active linked protocol player when outputting via a protocol, otherwise
1094 this player itself. The output player's value (or its provider's entry
1095 default) takes precedence; this player's own value is the fallback for keys
1096 the output player has no entry for.
1097
1098 :param key: The config key to retrieve.
1099 :param default: Value to return when the key is not present in any config.
1100 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
1101 Note: This parameter is used purely for static type checking and does not
1102 perform runtime type validation. Callers are responsible for ensuring the
1103 specified type matches the actual config value type.
1104 """
1105 output_player = self.resolve_output_player()
1106 if output_player is not self and key in output_player.config.values:
1107 return output_player.config.get_value(key, default)
1108 return self.get_config_value(key, default)
1109
1110 async def on_config_updated(self) -> None:
1111 """
1112 Handle logic when the player is loaded or updated.
1113
1114 Override this method in your player implementation if you need
1115 to perform any additional setup logic after the player is registered and
1116 the self.config was loaded, and whenever the config changes.
1117 """
1118 return
1119
1120 async def on_unload(self) -> None:
1121 """Handle logic when the player is unloaded from the Player controller."""
1122 for callback in self._on_unload_callbacks:
1123 try:
1124 callback()
1125 except Exception as err:
1126 self.logger.error(
1127 "Error calling on_unload callback for player %s: %s",
1128 self.player_id,
1129 err,
1130 )
1131
1132 async def group_with(self, target_player_id: str) -> None:
1133 """
1134 Handle GROUP_WITH command on the player.
1135
1136 Group this player to the given syncleader/target.
1137 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
1138
1139 :param target_player: player_id of the target player / sync leader.
1140 """
1141 # convenience helper method
1142 # no need to implement unless your player/provider has an optimized way to execute this
1143 # default implementation will simply call set_members
1144 # to add the target player to the group.
1145 target_player = self.mass.players.get_player(target_player_id, raise_unavailable=True)
1146 assert target_player # for type checking
1147 await target_player.set_members(player_ids_to_add=[self.player_id])
1148
1149 async def ungroup(self) -> None:
1150 """
1151 Handle UNGROUP command on the player.
1152
1153 Remove the player from any (sync)groups it currently is grouped to.
1154 If this player is the sync leader (or group player),
1155 all child's will be ungrouped and the group dissolved.
1156
1157 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
1158 """
1159 # convenience helper method
1160 # no need to implement unless your player/provider has an optimized way to execute this
1161 # default implementation will simply call set_members
1162 if self.synced_to:
1163 if parent_player := self.mass.players.get_player(self.synced_to):
1164 # if this player is synced to another player, remove self from that group
1165 await parent_player.set_members(player_ids_to_remove=[self.player_id])
1166 elif self.group_members:
1167 await self.set_members(player_ids_to_remove=self.group_members)
1168
1169 def on_protocol_player_updated(
1170 self, protocol_player: Player, changed_values: dict[str, tuple[Any, Any]]
1171 ) -> None:
1172 """Handle callback when one of the linked protocol players of the player is updated."""
1173 # optional callback
1174 # default implementation will simply trigger an update for the state of the player
1175 self.mass.players.trigger_player_update(self.player_id)
1176
1177 def on_protocol_parent_updated(
1178 self, protocol_parent: Player, changed_values: dict[str, tuple[Any, Any]]
1179 ) -> None:
1180 """Handle callback when the parent protocol player of the player is updated."""
1181 # optional callback
1182 # default implementation will simply trigger an update for the state of the player
1183 self.mass.players.trigger_player_update(self.player_id)
1184
1185 def on_group_member_updated(
1186 self, member_player: Player, changed_values: dict[str, tuple[Any, Any]]
1187 ) -> None:
1188 """Handle callback when a group member of the group player is updated."""
1189 # optional callback
1190 # default implementation will simply trigger an update for the state of the player
1191 self.mass.players.trigger_player_update(self.player_id)
1192
1193 def on_group_updated(
1194 self, group_player: Player, changed_values: dict[str, tuple[Any, Any]]
1195 ) -> None:
1196 """Handle callback when a group player is updated this player is a member of."""
1197 # optional callback
1198 # default implementation will simply trigger an update for the state of the player
1199 self.mass.players.trigger_player_update(self.player_id)
1200
1201 def on_sync_parent_updated(
1202 self, sync_parent: Player, changed_values: dict[str, tuple[Any, Any]]
1203 ) -> None:
1204 """Handle callback when the sync parent of this player is updated."""
1205 # optional callback
1206 # default implementation will simply trigger an update for the state of the player
1207 self.mass.players.trigger_player_update(self.player_id)
1208
1209 def _on_player_media_updated(self) -> None: # noqa: B027
1210 """Handle callback when the current media of the player is updated."""
1211 # optional callback for players that want to be informed when the final
1212 # current media is updated (after applying group/sync membership logic).
1213 # for instance to update any display information on the physical player.
1214
1215 # DO NOT OVERWRITE BELOW !
1216 # These properties and methods are either managed by core logic or they
1217 # are used to perform a very specific function. Overwriting these may
1218 # produce undesirable effects.
1219
1220 @property
1221 @final
1222 def player_id(self) -> str:
1223 """Return the id of the player."""
1224 return self._player_id
1225
1226 @property
1227 @final
1228 def provider(self) -> PlayerProvider:
1229 """Return the provider of the player."""
1230 return self._provider
1231
1232 @property
1233 @final
1234 def provider_id(self) -> str:
1235 """Return the provider (instance) id of the player."""
1236 return self._provider.instance_id
1237
1238 @property
1239 @final
1240 def translation_owner(self) -> str:
1241 """Return the translation owner namespace ("provider.<domain>") of the player's provider."""
1242 return self._provider.translation_owner
1243
1244 @property
1245 @final
1246 def config(self) -> PlayerConfig:
1247 """Return the config of the player."""
1248 return self._config
1249
1250 @property
1251 @final
1252 def extra_attributes(self) -> dict[str, EXTRA_ATTRIBUTES_TYPES]:
1253 """
1254 Return the extra attributes of the player.
1255
1256 This is a dict that can be used to pass any extra (serializable)
1257 attributes over the API, to be consumed by the UI (or another APi client, such as HA).
1258 This is not persisted and not used or validated by the core logic.
1259 """
1260 return self._extra_attributes
1261
1262 @property
1263 @final
1264 def extra_data(self) -> dict[str, Any]:
1265 """
1266 Return the extra data of the player.
1267
1268 This is a dict that can be used to store any extra data
1269 that is not part of the player state or config.
1270 This is not persisted and not exposed on the API.
1271 """
1272 return self._extra_data
1273
1274 @cached_property
1275 @final
1276 def display_name(self) -> str:
1277 """Return the (FINAL) display name of the player."""
1278 if custom_name := self._config.name:
1279 # always prefer the custom name over the default name
1280 return custom_name
1281 return self.name or self._config.default_name or self.player_id
1282
1283 @cached_property
1284 @final
1285 def enabled(self) -> bool:
1286 """Return if the player is enabled."""
1287 return self._config.enabled
1288
1289 @final
1290 def get_supported_sample_rates(self) -> list[tuple[int, int]]:
1291 """
1292 Return the resolved (sample_rate, bit_depth) pairs the player can play.
1293
1294 Honors, in order:
1295 1. The ``supported_sample_rates`` property (declarative or overridden)
1296 2. The user's ``CONF_SAMPLE_RATES`` selection
1297 3. A safe ``[(44100, 16)]`` fallback
1298 """
1299 if (declared := self.supported_sample_rates) is not None:
1300 return declared
1301 config_rates: list[tuple[int, int]] = []
1302 if conf := self.config.get_value(CONF_SAMPLE_RATES):
1303 conf = cast("list[str]", conf)
1304 for item in conf:
1305 # tolerate legacy/malformed entries: anything that does not parse as
1306 # `<rate><splitter><bit_depth>` is skipped and we fall back to defaults
1307 try:
1308 sample_rate_str, bit_depth_str = item.split(MULTI_VALUE_SPLITTER, 1)
1309 config_rates.append((int(sample_rate_str.strip()), int(bit_depth_str.strip())))
1310 except ValueError, TypeError:
1311 self.logger.warning(
1312 "Ignoring malformed CONF_SAMPLE_RATES entry %r for player %s",
1313 item,
1314 self.player_id,
1315 )
1316 return config_rates or [(44100, 16)]
1317
1318 @property
1319 @final
1320 def declares_supported_sample_rates(self) -> bool:
1321 """
1322 Return True when this player exposes its supported rates without user config.
1323
1324 Used by the config controller to decide whether to inject the generic
1325 ``CONF_ENTRY_SAMPLE_RATES`` option in the player config UI.
1326 """
1327 return self.supported_sample_rates is not None
1328
1329 @property
1330 @final
1331 def initialized(self) -> asyncio.Event:
1332 """
1333 Return if the player is initialized.
1334
1335 Used by player controller to indicate initial registration completed.
1336 """
1337 return self.__initialized
1338
1339 @property
1340 def corrected_elapsed_time(self) -> float | None:
1341 """Return the corrected/realtime elapsed time."""
1342 if self.elapsed_time is None or self.elapsed_time_last_updated is None:
1343 return None
1344 if self.playback_state == PlaybackState.PLAYING:
1345 return _clamp_elapsed_time(
1346 self.elapsed_time + (time.time() - self.elapsed_time_last_updated)
1347 )
1348 return _clamp_elapsed_time(self.elapsed_time)
1349
1350 @cached_property
1351 @final
1352 def icon(self) -> str:
1353 """Return the player icon."""
1354 # players without an icon config entry (e.g. protocol players) serve the fallback id
1355 icon = self._config.get_value(CONF_ENTRY_PLAYER_ICON.key)
1356 return cast("str", icon or CONF_ENTRY_PLAYER_ICON.default_value)
1357
1358 @cached_property
1359 @final
1360 def power_control(self) -> str:
1361 """Return the power control type."""
1362 conf = self.mass.config.get_raw_player_config_value(self.player_id, CONF_POWER_CONTROL)
1363 if conf and conf in (PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_FAKE, PLAYER_CONTROL_NONE):
1364 # validate that NATIVE is still backed by an actual POWER feature.
1365 # this handles graceful degradation for players (e.g. group players)
1366 # that previously advertised POWER but no longer do.
1367 if conf == PLAYER_CONTROL_NATIVE and PlayerFeature.POWER not in self.supported_features:
1368 return PLAYER_CONTROL_NONE
1369 return str(conf)
1370 if conf and (_control := self.mass.players.get_player_control(str(conf))):
1371 # the control type is explicitly set to a player control,
1372 return _control.id
1373 # handle auto-select logic if not explicitly set in config
1374 if PlayerFeature.POWER in self.supported_features:
1375 # player supports native power control, always prefer that
1376 return PLAYER_CONTROL_NATIVE
1377 return PLAYER_CONTROL_NONE
1378
1379 @cached_property
1380 @final
1381 def volume_control(self) -> str:
1382 """Return the volume control type."""
1383 conf = self.mass.config.get_raw_player_config_value(self.player_id, CONF_VOLUME_CONTROL)
1384 if conf and conf in (PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_FAKE, PLAYER_CONTROL_NONE):
1385 # the control type is explicitly set in the config, use that
1386 return str(conf)
1387 if conf and conf not in (PLAYER_CONTROL_PROTOCOL, "auto"):
1388 # the control type is explicitly set to a (protocol) player_id or player control,
1389 # check if it exists and is (currently) available
1390 if (_player := self.mass.players.get_player(str(conf))) and _player.available:
1391 return _player.player_id
1392 if _control := self.mass.players.get_player_control(str(conf)):
1393 return _control.id
1394 # handle auto-select logic if not explicitly set in config
1395 if PlayerFeature.VOLUME_SET in self.supported_features:
1396 # player supports native volume control, always prefer that
1397 return PLAYER_CONTROL_NATIVE
1398 # check for protocol player with volume support, and use that if found
1399 if protocol_player := self._get_protocol_player_for_feature(
1400 PlayerFeature.VOLUME_SET, require_active=False
1401 ):
1402 return protocol_player.player_id
1403 return PLAYER_CONTROL_NONE
1404
1405 @cached_property
1406 @final
1407 def mute_control(self) -> str:
1408 """Return the mute control type."""
1409 conf = self.mass.config.get_raw_player_config_value(self.player_id, CONF_MUTE_CONTROL)
1410 if conf == PLAYER_CONTROL_FAKE and self.volume_control == PLAYER_CONTROL_NONE:
1411 # fake mute is simulated by setting the volume to zero, so without a volume
1412 # control to drive there is no way to mute this player at all
1413 return PLAYER_CONTROL_NONE
1414 if conf and conf in (PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_FAKE, PLAYER_CONTROL_NONE):
1415 # the control type is explicitly set in the config, use that
1416 return str(conf)
1417 if conf and conf not in (PLAYER_CONTROL_PROTOCOL, "auto"):
1418 # the control type is explicitly set to a (protocol) player_id or player control,
1419 # check if it exists and is (currently) available
1420 if (_player := self.mass.players.get_player(str(conf))) and _player.available:
1421 return _player.player_id
1422 if _control := self.mass.players.get_player_control(str(conf)):
1423 return _control.id
1424 # handle auto-select logic if not explicitly set in config
1425 if PlayerFeature.VOLUME_MUTE in self.supported_features:
1426 # player supports native mute control, always prefer that
1427 return PLAYER_CONTROL_NATIVE
1428 # check for protocol player with mute support, and use that if found
1429 if protocol_player := self._get_protocol_player_for_feature(
1430 PlayerFeature.VOLUME_MUTE, require_active=False
1431 ):
1432 return protocol_player.player_id
1433 return PLAYER_CONTROL_NONE
1434
1435 @cached_property
1436 @final
1437 def group_volume(self) -> int | None:
1438 """
1439 Return the group volume level.
1440
1441 For group players or syncgroups, returns the maximum volume level of all
1442 powered-on child players, or None if no children support volume control.
1443
1444 For non-group players, returns the player's own volume level.
1445 """
1446 if len(self.state.group_members) == 0:
1447 # player is not a group or syncgroup
1448 if self.state.volume_control == PLAYER_CONTROL_NONE:
1449 return None
1450 return self.state.volume_level
1451 # return the maximum volume of all (turned on) child players
1452 group_volume: int | None = None
1453 for child_player in self.mass.players.iter_group_members(
1454 self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER
1455 ):
1456 if child_player.state.volume_control == PLAYER_CONTROL_NONE:
1457 continue
1458 if (child_volume := child_player.state.volume_level) is None:
1459 continue
1460 if group_volume is None or child_volume > group_volume:
1461 group_volume = child_volume
1462 return group_volume
1463
1464 @cached_property
1465 @final
1466 def group_volume_muted(self) -> bool | None:
1467 """
1468 Return the group mute state.
1469
1470 If this player is a group player or syncgroup, this will return True if all (powered on)
1471 child players in the group are muted, False if at least one is not muted, or None if
1472 none of the players within the group support mute control.
1473
1474 If the player is not a group player or syncgroup, this will return the mute state of the
1475 player itself (if set), or None if not supported.
1476 """
1477 if len(self.state.group_members) == 0:
1478 # player is not a group or syncgroup
1479 if self.state.mute_control == PLAYER_CONTROL_NONE:
1480 return None
1481 return self.state.volume_muted
1482 # calculate group mute state from all (turned on) players
1483 any_unmuted = False
1484 any_muted = False
1485 for child_player in self.mass.players.iter_group_members(
1486 self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER
1487 ):
1488 if child_player.state.mute_control == PLAYER_CONTROL_NONE:
1489 continue
1490 if (child_muted := child_player.state.volume_muted) is None:
1491 continue
1492 if child_muted:
1493 any_muted = True
1494 else:
1495 any_unmuted = True
1496 if any_unmuted and not any_muted:
1497 return False
1498 if any_muted and not any_unmuted:
1499 return True
1500 return None
1501
1502 @cached_property
1503 @final
1504 def hide_in_ui(self) -> bool:
1505 """
1506 Return the hide player in UI options.
1507
1508 This is a convenience property based on the config entry.
1509 """
1510 return bool(self._config.get_value(CONF_HIDE_IN_UI, self.hidden_by_default))
1511
1512 @cached_property
1513 @final
1514 def expose_to_ha(self) -> bool:
1515 """
1516 Return if the player should be exposed to Home Assistant.
1517
1518 This is a convenience property that returns True if the player is set to be exposed
1519 to Home Assistant, based on the config entry.
1520 """
1521 return bool(self._config.get_value(CONF_EXPOSE_PLAYER_TO_HA, self.expose_to_ha_by_default))
1522
1523 @cached_property
1524 @final
1525 def flow_mode(self) -> bool:
1526 """
1527 Return if the player(protocol) needs flow mode.
1528
1529 Will use 'requires_flow_mode' unless overridden by flow_mode config.
1530 """
1531 # Check config override
1532 if bool(self._config.get_value(CONF_FLOW_MODE)) is True:
1533 # flow mode explicitly enabled in config
1534 return True
1535 return self.requires_flow_mode
1536
1537 @property
1538 @final
1539 def supports_enqueue(self) -> bool:
1540 """
1541 Return if the player supports enqueueing tracks.
1542
1543 This considers the active output protocol's capabilities if one is active.
1544 If a protocol player is active, checks that protocol's ENQUEUE feature.
1545 Otherwise checks the native player's ENQUEUE feature.
1546 """
1547 return self._check_feature_with_active_protocol(PlayerFeature.ENQUEUE)
1548
1549 @property
1550 @final
1551 def supports_gapless(self) -> bool:
1552 """
1553 Return if the player supports gapless playback.
1554
1555 This considers the active output protocol's capabilities if one is active.
1556 If a protocol player is active, checks that protocol's GAPLESS_PLAYBACK feature.
1557 Otherwise checks the native player's GAPLESS_PLAYBACK feature.
1558 """
1559 return self._check_feature_with_active_protocol(PlayerFeature.GAPLESS_PLAYBACK)
1560
1561 @property
1562 @final
1563 def state(self) -> PlayerState:
1564 """Return the current (and FINAL) PlayerState of the player."""
1565 return self._state
1566
1567 # Protocol-related properties and helpers
1568
1569 @cached_property
1570 @final
1571 def is_native_player(self) -> bool:
1572 """Return True if this player is a native player."""
1573 is_universal_player = self.provider.domain == "universal_player"
1574 has_play_media = PlayerFeature.PLAY_MEDIA in self.supported_features
1575 return self.type != PlayerType.PROTOCOL and not is_universal_player and has_play_media
1576
1577 @cached_property
1578 @final
1579 def output_protocols(self) -> list[OutputProtocol]:
1580 """
1581 Return all output options for this player.
1582
1583 Includes:
1584 - Native playback (if player supports PLAY_MEDIA and is not a protocol/universal player)
1585 - Active protocol players from linked_output_protocols
1586 - Disabled protocols from cached linked_protocol_ids in config
1587
1588 Each entry has an available flag indicating current availability.
1589 """
1590 result: list[OutputProtocol] = []
1591
1592 # Add native playback option if applicable
1593 if self.is_native_player:
1594 result.append(
1595 OutputProtocol(
1596 output_protocol_id="native",
1597 name=self.provider.name,
1598 protocol_domain=self.provider.domain,
1599 priority=0, # Native is always highest priority
1600 available=self.available_for_playback,
1601 is_native=True,
1602 )
1603 )
1604 elif (
1605 self.provider.domain in PROTOCOL_PRIORITY
1606 and PlayerFeature.SET_MEMBERS in self.supported_features
1607 ):
1608 # Player is itself a native endpoint of a known protocol domain.
1609 result.append(
1610 OutputProtocol(
1611 output_protocol_id=self.player_id,
1612 name=self.provider.name,
1613 protocol_domain=self.provider.domain,
1614 priority=PROTOCOL_PRIORITY[self.provider.domain],
1615 available=self.available_for_playback,
1616 is_native=True,
1617 )
1618 )
1619
1620 # Add active protocol players
1621 active_ids: set[str] = set()
1622 for linked in self.__attr_linked_protocols:
1623 active_ids.add(linked.output_protocol_id)
1624 # Check if the protocol player is actually available
1625 protocol_player = self.mass.players.get_player(linked.output_protocol_id)
1626 is_available = protocol_player.available_for_playback if protocol_player else False
1627 # Use provider name if available, else domain title
1628 if protocol_player:
1629 name = protocol_player.provider.name
1630 else:
1631 name = linked.protocol_domain.title() if linked.protocol_domain else "Unknown"
1632 result.append(
1633 OutputProtocol(
1634 output_protocol_id=linked.output_protocol_id,
1635 name=name,
1636 protocol_domain=linked.protocol_domain,
1637 priority=linked.priority,
1638 available=is_available,
1639 derived_from=linked.derived_from,
1640 )
1641 )
1642
1643 # Add disabled protocols from cache
1644 cached_protocol_ids: list[str] = self.mass.config.get(
1645 f"{CONF_PLAYERS}/{self.player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
1646 [],
1647 )
1648 for protocol_id in cached_protocol_ids:
1649 if protocol_id in active_ids:
1650 continue # Already included above
1651 # Get stored config to determine protocol domain
1652 if raw_conf := self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}"):
1653 provider_id = raw_conf.get("provider", "")
1654 protocol_domain = provider_id.split("--")[0] if provider_id else "unknown"
1655 priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
1656 # resolve the persisted derived-transport edge (if any) so derived
1657 # outputs keep their base reference even while not registered
1658 derived_from = raw_conf.get("values", {}).get(CONF_UNDERLYING_PLAYER_ID)
1659 if derived_from == self.player_id:
1660 derived_from = "native"
1661 result.append(
1662 OutputProtocol(
1663 output_protocol_id=protocol_id,
1664 name=protocol_domain.title(),
1665 protocol_domain=protocol_domain,
1666 priority=priority,
1667 available=False, # Disabled protocols are not available
1668 derived_from=derived_from,
1669 )
1670 )
1671
1672 # Sort by priority (lower = more preferred)
1673 result.sort(key=lambda o: o.priority)
1674 return result
1675
1676 @property
1677 @final
1678 def linked_output_protocols(self) -> list[OutputProtocol]:
1679 """Return the list of actively linked output protocol players."""
1680 return self.__attr_linked_protocols
1681
1682 @property
1683 @final
1684 def protocol_parent_id(self) -> str | None:
1685 """Return the parent player_id if this is a protocol player linked to a native player."""
1686 return self.__attr_protocol_parent_id
1687
1688 @property
1689 @final
1690 def underlying_player_id(self) -> str | None:
1691 """
1692 Return the player_id this (derived) protocol player runs on top of, if any.
1693
1694 Set by bridge implementations (e.g. a Sendspin bridge riding on an AirPlay
1695 player) so the protocol linking layer can resolve the parent deterministically
1696 instead of relying on device identifier matching.
1697 """
1698 return self._attr_underlying_player_id
1699
1700 @property
1701 @final
1702 def active_output_protocol(self) -> str | None:
1703 """Return the currently active output protocol ID."""
1704 return self.__attr_active_output_protocol
1705
1706 @final
1707 def set_active_output_protocol(self, protocol_id: str | None) -> None:
1708 """
1709 Set the currently active output protocol ID.
1710
1711 :param protocol_id: The protocol player_id to set as active, "native" for native playback,
1712 or None to clear the active protocol.
1713 """
1714 # cancel any pending scheduled protocol clear,
1715 # as we're explicitly setting it now
1716 self.mass.cancel_task(f"clear_active_protocol_{self.player_id}")
1717 if self.__attr_active_output_protocol == protocol_id:
1718 return # No change
1719 if protocol_id == self.player_id:
1720 protocol_id = "native" # Normalize to "native" for native player
1721 if protocol_id:
1722 protocol_name = protocol_id
1723 if protocol_id == "native":
1724 protocol_name = "Native"
1725 elif protocol_player := self.mass.players.get_player(protocol_id):
1726 protocol_name = protocol_player.provider.name
1727 self.logger.info(
1728 "Setting active output protocol on %s to %s",
1729 self.display_name,
1730 protocol_name,
1731 )
1732 else:
1733 self.logger.info(
1734 "Clearing active output protocol on %s",
1735 self.display_name,
1736 )
1737 self.__attr_active_output_protocol = protocol_id
1738 self.update_state()
1739
1740 @final
1741 def set_linked_output_protocols(self, protocols: list[OutputProtocol]) -> None:
1742 """
1743 Set the actively linked output protocol players.
1744
1745 :param protocols: List of OutputProtocol objects representing active protocol players.
1746 """
1747 self.__attr_linked_protocols = protocols
1748 self.mass.players.trigger_player_update(self.player_id)
1749
1750 @final
1751 def set_protocol_parent_id(self, parent_id: str | None) -> None:
1752 """
1753 Set the parent player_id for protocol players.
1754
1755 :param parent_id: The player_id of the parent player, or None to clear.
1756 """
1757 self.__attr_protocol_parent_id = parent_id
1758 self.mass.players.trigger_player_update(self.player_id)
1759
1760 @final
1761 def get_linked_protocol(self, protocol_domain: str) -> OutputProtocol | None:
1762 """Get a linked protocol by domain with current availability."""
1763 for linked in self.__attr_linked_protocols:
1764 if linked.protocol_domain == protocol_domain:
1765 protocol_player = self.mass.players.get_player(linked.output_protocol_id)
1766 current_available = (
1767 protocol_player.available_for_playback if protocol_player else False
1768 )
1769 return OutputProtocol(
1770 output_protocol_id=linked.output_protocol_id,
1771 name=protocol_player.provider.name
1772 if protocol_player
1773 else linked.protocol_domain.title(),
1774 protocol_domain=linked.protocol_domain,
1775 priority=linked.priority,
1776 available=current_available,
1777 is_native=False,
1778 derived_from=linked.derived_from,
1779 )
1780 return None
1781
1782 @final
1783 def get_output_protocol_by_domain(self, protocol_domain: str) -> OutputProtocol | None:
1784 """
1785 Get an output protocol by domain, including native protocol.
1786
1787 Unlike get_linked_protocol, this also checks if the player's native protocol
1788 matches the requested domain.
1789
1790 :param protocol_domain: The protocol domain to search for (e.g., "airplay", "sonos").
1791 """
1792 for output_protocol in self.output_protocols:
1793 if output_protocol.protocol_domain == protocol_domain:
1794 return output_protocol
1795 return None
1796
1797 @final
1798 def get_protocol_player(self, player_id: str) -> Player | None:
1799 """Get the protocol Player for a given player_id."""
1800 if player_id == "native":
1801 return self if PlayerFeature.PLAY_MEDIA in self.supported_features else None
1802 return self.mass.players.get_player(player_id)
1803
1804 @final
1805 def get_preferred_protocol_player(self) -> Player | None:
1806 """Get the best available protocol player by priority."""
1807 for linked in sorted(self.__attr_linked_protocols, key=lambda x: x.priority):
1808 if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
1809 if protocol_player.available_for_playback:
1810 return protocol_player
1811 return None
1812
1813 @final
1814 def mark_state_dirty(self) -> None:
1815 """
1816 Mark the player's (final) state as dirty.
1817
1818 Forces the next update_state call to recalculate the full PlayerState.
1819 Must be called when state the player derives from changed outside the
1820 player's own attributes (e.g. group topology, linked protocol players,
1821 the active queue) - the player controller does this automatically for
1822 all its notification paths (trigger_player_update and the state fan-out).
1823 """
1824 self.__state_dirty = True
1825
1826 @final
1827 def refresh_state(self, signal_event: bool = True) -> None:
1828 """
1829 Recalculate the player state unconditionally.
1830
1831 Convenience shorthand for mark_state_dirty() + update_state(), for core
1832 code reacting to changes outside the player's own attributes.
1833
1834 :param signal_event: If True, signal the state update event to the PlayerController.
1835 """
1836 self.mark_state_dirty()
1837 self.update_state(signal_event=signal_event)
1838
1839 @final
1840 def update_state(self, force_update: bool = False, signal_event: bool = True) -> None:
1841 """
1842 Update the PlayerState from the current state of the player.
1843
1844 This method should be called to update the player's state
1845 and signal any changes to the PlayerController.
1846
1847 :param force_update: If True, always recalculate the state, even when no
1848 (known) own input changed. An update event still only fires when the
1849 recalculated state actually differs.
1850 :param signal_event: If True, signal the state update event to the PlayerController.
1851 """
1852 self.mass.verify_event_loop_thread("player.update_state")
1853 # Invalidate the cached properties up front so both the input probe and
1854 # a recalculation read fresh values; only the config-derived cached
1855 # properties are retained (set_config invalidates those).
1856 for key in list(self._cache):
1857 if key not in _CONFIG_CACHED_PROPS:
1858 del self._cache[key]
1859 new_snapshot = self.__collect_input_snapshot()
1860 if (
1861 not force_update
1862 and not self.__state_dirty
1863 and self.__input_snapshot is not None
1864 and new_snapshot == self.__input_snapshot
1865 and not self.__own_position_anchor_moved()
1866 ):
1867 # None of the player's own inputs changed since the last calculation:
1868 # nothing to do. Changes the player derives from other sources
1869 # (players/queues/config) always come with a mark_state_dirty call.
1870 return
1871 self.__state_dirty = False
1872 self.__input_snapshot = new_snapshot
1873 current_media = self.current_media
1874 self.__input_anchor = (
1875 (self._attr_elapsed_time, self._attr_elapsed_time_last_updated),
1876 (current_media.elapsed_time, current_media.elapsed_time_last_updated)
1877 if current_media is not None
1878 else None,
1879 self.playback_state == PlaybackState.PLAYING,
1880 )
1881 # calculate the new state
1882 changed_values, position_jumped, media_position_jumped = self.__calculate_player_state()
1883 if not MEDIA_IDENTITY_KEYS.isdisjoint(changed_values.keys()):
1884 # current media changed, call the media updated callback
1885 # debounce the callback to avoid multiple calls when multiple
1886 # state updates happen in a short time
1887 self.mass.call_later(
1888 1, self._on_player_media_updated, task_id=f"player_media_updated_{self.player_id}"
1889 )
1890 # persist the default name if it changed
1891 if self.name and self.config.default_name != self.name:
1892 self.mass.config.set_player_default_name(self.player_id, self.name)
1893 # persist the player type if it changed
1894 if self.type != self._config.player_type:
1895 self.mass.config.set_player_type(self.player_id, self.type)
1896 if position_jumped and signal_event:
1897 # the corrected playback position jumped (seek or buffer correction):
1898 # this is not an event by itself (only current_media is event-relevant)
1899 # but the queue timing must re-base on the fresh position right away
1900 self.mass.players.on_player_position_jumped(self)
1901 # return early if nothing changed (unless force_update is True)
1902 if len(changed_values) == 0 and not force_update:
1903 return
1904
1905 # signal the state update to the PlayerController
1906 if signal_event:
1907 self.mass.players.signal_player_state_update(
1908 self, changed_values, media_position_jumped=media_position_jumped
1909 )
1910
1911 @final
1912 def set_current_media( # noqa: PLR0913
1913 self,
1914 uri: str,
1915 media_type: MediaType = MediaType.UNKNOWN,
1916 title: str | None = None,
1917 artist: str | None = None,
1918 album: str | None = None,
1919 image_url: str | None = None,
1920 duration: int | None = None,
1921 source_id: str | None = None,
1922 queue_item_id: str | None = None,
1923 custom_data: dict[str, Any] | None = None,
1924 clear_all: bool = False,
1925 ) -> None:
1926 """
1927 Set current_media helper.
1928
1929 Assumes use of '_attr_current_media'.
1930 """
1931 if self._attr_current_media is None or clear_all:
1932 self._attr_current_media = PlayerMedia(
1933 uri=uri,
1934 media_type=media_type,
1935 )
1936 self._attr_current_media.uri = uri
1937 if media_type != MediaType.UNKNOWN:
1938 self._attr_current_media.media_type = media_type
1939 if title:
1940 self._attr_current_media.title = title
1941 if artist:
1942 self._attr_current_media.artist = artist
1943 if album:
1944 self._attr_current_media.album = album
1945 if image_url:
1946 self._attr_current_media.image_url = image_url
1947 if duration:
1948 self._attr_current_media.duration = duration
1949 if source_id:
1950 self._attr_current_media.source_id = source_id
1951 if queue_item_id:
1952 self._attr_current_media.queue_item_id = queue_item_id
1953 if custom_data:
1954 self._attr_current_media.custom_data = custom_data
1955
1956 @final
1957 def set_resolved_palette(self, image_url: str, palette: MediaItemPalette) -> None:
1958 """
1959 Store the resolved color palette for the currently shown image.
1960
1961 The palette is resolved asynchronously (from the cache controller) by the
1962 PlayerController; it is carried on the player here so the synchronous state
1963 serialization can attach it without blocking. May only be called by the
1964 PlayerController.
1965
1966 :param image_url: Image URL the palette was extracted from.
1967 :param palette: The extracted color palette.
1968 """
1969 self._attr_current_palette_url = image_url
1970 self._attr_current_palette = palette
1971
1972 @final
1973 def set_config(self, config: PlayerConfig) -> None:
1974 """
1975 Set/update the player config.
1976
1977 May only be called by the PlayerController.
1978 """
1979 # TODO: validate that caller is the PlayerController ?
1980 self._config = config
1981 # config feeds several (cached) state values, so invalidate all cached
1982 # properties (including the config-derived ones) and force a recalculation
1983 self._cache.clear()
1984 self.mark_state_dirty()
1985
1986 @final
1987 def set_initialized(self) -> None:
1988 """Set the player as initialized."""
1989 self.__initialized.set()
1990
1991 @final
1992 def to_dict(self) -> dict[str, Any]:
1993 """Return the (serializable) dict representation of the Player."""
1994 return self.state.to_dict()
1995
1996 @final
1997 def supports_feature(self, feature: PlayerFeature) -> bool:
1998 """Return True if this player supports the given feature."""
1999 return feature in self.supported_features
2000
2001 @final
2002 def check_feature(self, feature: PlayerFeature) -> None:
2003 """Check if this player supports the given feature."""
2004 if not self.supports_feature(feature):
2005 raise UnsupportedFeaturedException(
2006 f"Player {self.display_name} does not support feature {feature.name}"
2007 )
2008
2009 def _update_setup_data(self, key: str, value: ConfigValueType, immediate: bool = True) -> None:
2010 """
2011 Update a single setup_data value for this player (e.g. a rotated pairing credential).
2012
2013 :param key: The setup data key to update.
2014 :param value: The new value; strings are encrypted at rest.
2015 :param immediate: Persist to disk right away (the default) instead of on the
2016 debounced save timer, so a critical value survives a crash.
2017 """
2018 if not self.mass.config.get(f"{CONF_PLAYERS}/{self.player_id}"):
2019 # only allow setting setup data if the main config entry exists
2020 msg = f"Invalid player: {self.player_id}"
2021 raise KeyError(msg)
2022 stored_value = self.mass.config.encrypt_string(value) if isinstance(value, str) else value
2023 self.mass.config.set(
2024 f"{CONF_PLAYERS}/{self.player_id}/setup_data/{key}",
2025 stored_value,
2026 immediate=immediate,
2027 )
2028 # keep the in-memory config copy in sync with storage
2029 self.config.setup_data[key] = stored_value
2030
2031 @final
2032 def _check_feature_with_active_protocol(
2033 self, feature: PlayerFeature, active_only: bool = False
2034 ) -> bool:
2035 """
2036 Check if a feature is supported considering the active output protocol.
2037
2038 If an active output protocol is set (and not native), checks that protocol
2039 player's features. Otherwise checks the native player's features.
2040
2041 :param feature: The PlayerFeature to check.
2042 :return: True if the feature is supported by the active protocol or native player.
2043 """
2044 # If active output protocol is set and not native, check protocol player's features
2045 if (
2046 self.__attr_active_output_protocol
2047 and self.__attr_active_output_protocol != "native"
2048 and (
2049 protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
2050 )
2051 ):
2052 return feature in protocol_player.supported_features
2053 # Otherwise check native player's features
2054 return feature in self.supported_features
2055
2056 @final
2057 def _get_protocol_player_for_feature(
2058 self,
2059 feature: PlayerFeature,
2060 require_active: bool = True,
2061 ) -> Player | None:
2062 """Get player(protocol) which has the given PlayerFeature."""
2063 # prefer native player
2064 if feature in self.supported_features:
2065 return self
2066 # prefer active (or preferred) protocol player with the feature
2067 active_protocol = self.active_output_protocol
2068 if active_protocol and active_protocol != "native":
2069 protocol_player = self.mass.players.get_player(active_protocol)
2070 if (
2071 protocol_player
2072 and protocol_player.available_for_playback
2073 and feature in protocol_player.supported_features
2074 ):
2075 return protocol_player
2076 if require_active:
2077 # if we require active and the active protocol
2078 # doesn't support the feature, return None
2079 return None
2080
2081 # fallback to preferred protocol from config
2082 preferred_conf = self.mass.config.get_raw_player_config_value(
2083 self.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
2084 )
2085 if preferred_conf and preferred_conf not in ("auto", "native"):
2086 preferred_protocol = str(preferred_conf)
2087 if (
2088 (_player := self.mass.players.get_player(preferred_protocol))
2089 and _player.available_for_playback
2090 and feature in _player.supported_features
2091 ):
2092 return _player
2093
2094 # Otherwise, use the first available linked protocol.
2095 # Prefer protocols that can process commands without active streaming
2096 # (cast/dlna can always handle volume, airplay/sendspin only while streaming).
2097 _control_priority = {"chromecast": 0, "dlna": 1, "airplay": 2, "sendspin": 3}
2098 for linked in sorted(
2099 self.linked_output_protocols,
2100 key=lambda o: _control_priority.get(o.protocol_domain, 10),
2101 ):
2102 if (
2103 (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
2104 and protocol_player.available_for_playback
2105 and feature in protocol_player.supported_features
2106 ):
2107 return protocol_player
2108
2109 return None
2110
2111 @final
2112 def __collect_input_snapshot(self) -> dict[str, Any]:
2113 """
2114 Collect a snapshot of the player's own state-calculation inputs.
2115
2116 Only covers inputs owned by the player itself (its _attr_ values and
2117 provider-overridden properties); state the player derives from other
2118 sources (players/queues/config) is covered by mark_state_dirty instead.
2119 The playback position anchors are deliberately excluded: they are
2120 tracked separately with jump detection (__own_position_anchor_moved).
2121 """
2122 current_media = self.current_media
2123 device_info = self._attr_device_info
2124 return {
2125 "type": self.type,
2126 "available": self.available,
2127 "name": self.name,
2128 "needs_setup": self.needs_setup,
2129 "setup_reason": self.setup_reason,
2130 "playback_state": self.playback_state,
2131 "powered": self.powered,
2132 "volume_level": self.volume_level,
2133 "volume_muted": self.volume_muted,
2134 "active_source": self.active_source,
2135 "active_sound_mode": self.active_sound_mode,
2136 "is_active_session": self.is_active_session,
2137 "synced_to": self.synced_to if self.__probe_synced_to else None,
2138 "supported_features": frozenset(self.supported_features),
2139 "group_members": tuple(self.group_members),
2140 "static_group_members": tuple(self.static_group_members),
2141 "can_group_with": frozenset(self.can_group_with),
2142 "device_info": (
2143 device_info.model,
2144 device_info.manufacturer,
2145 device_info.software_version,
2146 device_info.model_id,
2147 device_info.manufacturer_id,
2148 tuple(sorted(device_info.identifiers.items())),
2149 ),
2150 "source_list": tuple(
2151 (s.id, s.name, s.passive, s.can_play_pause, s.can_seek, s.can_next_previous)
2152 for s in self.source_list
2153 ),
2154 "sound_mode_list": tuple((m.id, m.name, m.passive) for m in self._attr_sound_mode_list),
2155 "options": tuple((o.key, o.value, o.read_only) for o in self._attr_options),
2156 "current_media": (
2157 (
2158 current_media.uri,
2159 current_media.media_type,
2160 current_media.title,
2161 current_media.artist,
2162 current_media.album,
2163 current_media.album_artist,
2164 current_media.image_url,
2165 current_media.duration,
2166 current_media.source_id,
2167 current_media.queue_item_id,
2168 )
2169 if current_media is not None
2170 else None
2171 ),
2172 "extra_attributes": tuple(
2173 sorted(
2174 (key, _freeze(value))
2175 for key, value in self._extra_attributes.items()
2176 if key not in ("seq_no", "last_poll")
2177 )
2178 ),
2179 "fake_controls": (
2180 self._extra_data.get(ATTR_FAKE_POWER),
2181 self._extra_data.get(ATTR_FAKE_VOLUME),
2182 self._extra_data.get(ATTR_FAKE_MUTE),
2183 ),
2184 "linked_protocols": tuple(
2185 (p.output_protocol_id, p.protocol_domain, p.priority, p.derived_from)
2186 for p in self.__attr_linked_protocols
2187 ),
2188 "protocol_parent_id": self.__attr_protocol_parent_id,
2189 "active_output_protocol": self.__attr_active_output_protocol,
2190 "active_mass_source": self.__active_mass_source,
2191 "sleep_timer_expires_at": self.__sleep_timer_expires_at,
2192 }
2193
2194 @final
2195 def __own_position_anchor_moved(self) -> bool:
2196 """Return whether one of the player's own position anchors moved significantly."""
2197 if (prev := self.__input_anchor) is None:
2198 return True
2199 prev_player_anchor, prev_media_anchor, prev_playing = prev
2200 playing = self.playback_state == PlaybackState.PLAYING
2201 media = self.current_media
2202 new_player_anchor = (self._attr_elapsed_time, self._attr_elapsed_time_last_updated)
2203 new_media_anchor = (
2204 (media.elapsed_time, media.elapsed_time_last_updated) if media is not None else None
2205 )
2206 return _anchor_moved(
2207 prev_player_anchor, new_player_anchor, prev_playing, playing
2208 ) or _anchor_moved(prev_media_anchor, new_media_anchor, prev_playing, playing)
2209
2210 @final
2211 def __calculate_player_state(
2212 self,
2213 ) -> tuple[dict[str, tuple[Any, Any]], bool, bool]:
2214 """
2215 Calculate the (current) and FINAL PlayerState.
2216
2217 This method is called when we're updating the player,
2218 and we compare the current state with the previous state to determine
2219 if we need to signal a state change to API consumers.
2220
2221 Returns a tuple of (changed state values, player position jumped,
2222 current_media position jumped). The player's own elapsed_time values
2223 are not part of the changed values: they refresh on every calculation
2224 but only current_media - which holds the final calculated position -
2225 is event-relevant. The jump flags drive the position correction logic.
2226 """
2227 playback_state, elapsed_time, elapsed_time_last_updated = self.__final_playback_state
2228 prev_state = self._state
2229 prev_fingerprint = self.__state_fingerprint or _state_fingerprint(prev_state)
2230 prev_playing = prev_state.playback_state == PlaybackState.PLAYING
2231 new_playing = playback_state == PlaybackState.PLAYING
2232 # detect a discrete jump of the corrected position (seek/buffer correction);
2233 # the fresh anchor is always adopted into the state
2234 _, _, position_jumped = _reconcile_position_anchor(
2235 prev_state.elapsed_time,
2236 prev_state.elapsed_time_last_updated,
2237 elapsed_time,
2238 elapsed_time_last_updated,
2239 prev_playing,
2240 new_playing,
2241 force_adopt=True,
2242 )
2243 self._state = PlayerState(
2244 player_id=self.player_id,
2245 provider=self.provider_id,
2246 type=self.type,
2247 available=self.enabled and self.available and not self.needs_setup,
2248 device_info=self.device_info,
2249 supported_features=self.__final_supported_features,
2250 playback_state=playback_state,
2251 elapsed_time=elapsed_time,
2252 elapsed_time_last_updated=elapsed_time_last_updated,
2253 powered=self.__final_power_state,
2254 volume_level=self.__final_volume_level,
2255 volume_muted=self.__final_volume_muted_state,
2256 group_members=UniqueList(self.__final_group_members),
2257 static_group_members=UniqueList(self.static_group_members),
2258 can_group_with=self.__final_can_group_with,
2259 synced_to=self.__final_synced_to,
2260 active_source=self.__final_active_source,
2261 source_list=self.__final_source_list,
2262 active_group=self.__final_active_group,
2263 current_media=self.__final_current_media,
2264 active_sound_mode=self.active_sound_mode,
2265 sound_mode_list=self.sound_mode_list,
2266 options=self.options,
2267 name=self.display_name,
2268 enabled=self.enabled,
2269 hide_in_ui=self.hide_in_ui,
2270 expose_to_ha=self.expose_to_ha,
2271 icon=self.icon,
2272 group_volume=self.group_volume,
2273 group_volume_muted=self.group_volume_muted,
2274 extra_attributes=self.extra_attributes,
2275 power_control=self.power_control,
2276 volume_control=self.volume_control,
2277 mute_control=self.mute_control,
2278 output_protocols=self.output_protocols,
2279 active_output_protocol=self.__attr_active_output_protocol,
2280 needs_setup=self.needs_setup,
2281 setup_reason=self.setup_reason,
2282 has_setup_flow=self.has_setup_flow,
2283 sleep_timer_expires_at=self.sleep_timer_expires_at,
2284 )
2285 media_position_jumped = self.__reconcile_current_media_anchor(
2286 prev_state, prev_playing, new_playing
2287 )
2288
2289 # track stop called state
2290 if (
2291 prev_state.playback_state == PlaybackState.IDLE
2292 and self._state.playback_state != PlaybackState.IDLE
2293 ):
2294 self.__stop_called = False
2295 elif (
2296 prev_state.playback_state != PlaybackState.IDLE
2297 and self._state.playback_state == PlaybackState.IDLE
2298 ):
2299 self.__stop_called = True
2300 # when we're going to idle,
2301 # we want to reset the active mass source after a short delay
2302 # this is done using a timer which gets reset if the player starts playing again
2303 # before the timer is up, using the task_id
2304 self.mass.call_later(
2305 5, self.set_active_mass_source, None, task_id=f"set_mass_source_{self.player_id}"
2306 )
2307 new_fingerprint = _state_fingerprint(self._state)
2308 self.__state_fingerprint = new_fingerprint
2309 changed_values: dict[str, tuple[Any, Any]] = {}
2310 for key in prev_fingerprint.keys() | new_fingerprint.keys():
2311 old_value = prev_fingerprint.get(key)
2312 new_value = new_fingerprint.get(key)
2313 if old_value != new_value:
2314 changed_values[key] = (old_value, new_value)
2315 if "current_media" in changed_values:
2316 # media appeared/disappeared: collapse the leaf keys into the single
2317 # top-level key carrying the actual (old, new) media objects
2318 for key in [key for key in changed_values if key.startswith("current_media.")]:
2319 del changed_values[key]
2320 changed_values["current_media"] = (prev_state.current_media, self._state.current_media)
2321 if "options" in changed_values:
2322 # the PLAYER_OPTIONS_UPDATED event carries the actual (old, new) options
2323 changed_values["options"] = (prev_state.options, self._state.options)
2324 return changed_values, position_jumped, media_position_jumped
2325
2326 @final
2327 def __reconcile_current_media_anchor(
2328 self, prev_state: PlayerState, prev_playing: bool, new_playing: bool
2329 ) -> bool:
2330 """
2331 Reconcile the position anchor on the freshly calculated current_media.
2332
2333 Keeps the previous anchor while it extrapolates to the same corrected
2334 position (steady playback), so regular ticks don't change the state.
2335
2336 Returns True when the corrected current_media position jumped (seek or
2337 buffer correction reached the current media).
2338 """
2339 prev_media = prev_state.current_media
2340 new_media = self._state.current_media
2341 if new_media is None or prev_media is None:
2342 return False
2343 if (new_media.queue_item_id or new_media.uri) != (
2344 prev_media.queue_item_id or prev_media.uri
2345 ):
2346 # different item loaded - adopt the new anchor as-is
2347 return False
2348 # Players that mirror another player's media (grouped/synced members,
2349 # protocol children) share the owner's PlayerMedia object, which the owner
2350 # already reconciled - only report the jump, never mutate the shared object.
2351 mirrors_parent = bool(
2352 self.__final_active_group
2353 or self.__final_synced_to
2354 or (self.type == PlayerType.PROTOCOL and self.__attr_protocol_parent_id)
2355 )
2356 position, timestamp, jumped = _reconcile_position_anchor(
2357 prev_media.elapsed_time,
2358 prev_media.elapsed_time_last_updated,
2359 new_media.elapsed_time,
2360 new_media.elapsed_time_last_updated,
2361 prev_playing,
2362 new_playing,
2363 force_adopt=mirrors_parent,
2364 )
2365 if not mirrors_parent:
2366 # steady playback resolves to the previous anchor, so nothing changed;
2367 # a jump (or a previous anchor that was still incomplete) adopts the new one
2368 new_media.elapsed_time = int(position) if position is not None else None
2369 new_media.elapsed_time_last_updated = timestamp
2370 return jumped
2371
2372 @cached_property
2373 @final
2374 def __final_playback_state(self) -> tuple[PlaybackState, float | None, float | None]:
2375 """
2376 Return the FINAL playback state based on the playercontrol which may have been set-up.
2377
2378 Returns a tuple of (playback_state, elapsed_time, elapsed_time_last_updated).
2379 """
2380 # Determine base state from protocol player, parent/group, or self.
2381 playback_state: PlaybackState
2382 elapsed_time: float | None
2383 elapsed_time_last_updated: float | None
2384
2385 # If an output protocol is active (and not native),
2386 # use the protocol player's state as the source of truth
2387 if (
2388 self.__attr_active_output_protocol
2389 and self.__attr_active_output_protocol != "native"
2390 and (
2391 protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
2392 )
2393 ):
2394 playback_state = protocol_player.state.playback_state
2395 elapsed_time = protocol_player.state.elapsed_time
2396 elapsed_time_last_updated = protocol_player.state.elapsed_time_last_updated
2397 # If we're synced to another player, mirror the leader's state so that
2398 # synced clients report the same playback info as their leader.
2399 elif (parent_id := self.__final_synced_to) and (
2400 parent_player := self.mass.players.get_player(parent_id)
2401 ):
2402 playback_state = parent_player.state.playback_state
2403 elapsed_time = parent_player.state.elapsed_time
2404 elapsed_time_last_updated = parent_player.state.elapsed_time_last_updated
2405 else:
2406 playback_state = self.playback_state
2407 elapsed_time = self.elapsed_time
2408 elapsed_time_last_updated = self.elapsed_time_last_updated
2409
2410 # If the active queue item is an AudioSource with upstream-clock
2411 # metadata (e.g. Spotify Connect / AirPlay / Yandex Ynison reporting
2412 # the source's logical position), prefer that over the protocol /
2413 # self elapsed_time — the latter tracks bytes consumed, which is the
2414 # wrong clock for live plugin sources (loses upstream seeks and
2415 # pause-resume on the queue's corrected_elapsed_time, which the
2416 # player_queues controller and several player providers consume).
2417 # A group player outputs the AudioSource from its own queue, which
2418 # __final_active_source may not resolve to, so the group's own queue
2419 # is also consulted.
2420 candidate_source_ids = [self.__final_active_source]
2421 if self.type == PlayerType.GROUP:
2422 candidate_source_ids.append(self.player_id)
2423 for source_id in candidate_source_ids:
2424 if (
2425 source_id
2426 and (queue := self.mass.player_queues.get(source_id))
2427 and (current_item := queue.current_item) is not None
2428 and (sd := current_item.streamdetails) is not None
2429 and sd.media_type == MediaType.AUDIO_SOURCE
2430 and sd.stream_metadata is not None
2431 and sd.stream_metadata.elapsed_time is not None
2432 ):
2433 elapsed_time = sd.stream_metadata.elapsed_time
2434 elapsed_time_last_updated = (
2435 sd.stream_metadata.elapsed_time_last_updated or time.time()
2436 )
2437 break
2438
2439 return (playback_state, elapsed_time, elapsed_time_last_updated)
2440
2441 @cached_property
2442 @final
2443 def __final_power_state(self) -> bool | None:
2444 """Return the FINAL power state based on the playercontrol which may have been set-up."""
2445 power_control = self.power_control
2446 if power_control == PLAYER_CONTROL_FAKE:
2447 return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
2448 if power_control == PLAYER_CONTROL_NATIVE:
2449 return self.powered
2450 if power_control == PLAYER_CONTROL_NONE:
2451 return None
2452 # handle protocol player as power control
2453 if player_ctrl := self.mass.players.get_player(power_control):
2454 if player_ctrl.powered is not None:
2455 return player_ctrl.powered
2456 # handle player control for power if set
2457 if ext_ctrl := self.mass.players.get_player_control(power_control):
2458 return ext_ctrl.power_state
2459 return None
2460
2461 @cached_property
2462 @final
2463 def __final_volume_level(self) -> int | None:
2464 """Return the FINAL volume level based on the playercontrol which may have been set-up."""
2465 volume_control = self.volume_control
2466 if volume_control == PLAYER_CONTROL_FAKE:
2467 # Fake volume is already stored as logical (0-100)
2468 return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
2469 if volume_control == PLAYER_CONTROL_NATIVE:
2470 # Scale device volume back to logical (0-100)
2471 if self.volume_level is None:
2472 return None
2473 return self.mass.players.scale_volume_from_device(self.player_id, self.volume_level)
2474 if volume_control == PLAYER_CONTROL_NONE:
2475 return None
2476 # handle protocol player as volume control
2477 if control := self.mass.players.get_player(volume_control):
2478 control_volume = control.volume_level
2479 if (
2480 control_volume == 0
2481 and control.player_id != self.active_output_protocol
2482 and any(
2483 linked.output_protocol_id == control.player_id
2484 for linked in self.linked_output_protocols
2485 )
2486 ):
2487 # A linked protocol interface that is not actively rendering audio
2488 # may report volume 0 while the device is in standby (e.g. the cast
2489 # side of some devices), which doesn't reflect the real device volume.
2490 # Treat it as unknown so we fall back to other sources instead of
2491 # propagating a spurious hard mute.
2492 control_volume = None
2493 if control_volume is not None:
2494 return self.mass.players.scale_volume_from_device(self.player_id, control_volume)
2495 # handle player control for volume if set
2496 if player_control := self.mass.players.get_player_control(volume_control):
2497 if player_control.volume_level is not None:
2498 return self.mass.players.scale_volume_from_device(
2499 self.player_id, player_control.volume_level
2500 )
2501 # control not (yet) available or has no volume, fall back to native
2502 if self.volume_level is None:
2503 return None
2504 return self.mass.players.scale_volume_from_device(self.player_id, self.volume_level)
2505
2506 @cached_property
2507 @final
2508 def __final_volume_muted_state(self) -> bool | None:
2509 """Return the FINAL mute state based on any playercontrol which may have been set-up."""
2510 mute_control = self.mute_control
2511 if mute_control == PLAYER_CONTROL_FAKE:
2512 return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
2513 if mute_control == PLAYER_CONTROL_NATIVE:
2514 return self.volume_muted
2515 if mute_control == PLAYER_CONTROL_NONE:
2516 return None
2517 # handle protocol player as mute control
2518 if control := self.mass.players.get_player(mute_control):
2519 if control.volume_muted is not None:
2520 return control.volume_muted
2521 # handle player control for mute if set
2522 if player_control := self.mass.players.get_player_control(mute_control):
2523 if player_control.volume_muted is not None:
2524 return player_control.volume_muted
2525 # control not (yet) available or has no mute state, fall back to native
2526 return self.volume_muted
2527
2528 @cached_property
2529 @final
2530 def __final_active_group(self) -> str | None:
2531 """
2532 Return the player id of any playergroup that is currently active for this player.
2533
2534 This will return the id of the groupplayer if any groups are active.
2535 If no groups are currently active, this will return None.
2536 """
2537 if self.type == PlayerType.PROTOCOL:
2538 # protocol players should not have an active group,
2539 # they follow the group state of their parent player
2540 return None
2541 for group_player in self.mass.players.iter_players(
2542 return_unavailable=False, return_disabled=False
2543 ):
2544 if group_player.type != PlayerType.GROUP:
2545 continue
2546 if group_player.player_id == self.player_id:
2547 continue
2548 # Use the raw `powered` attribute (not `state.powered`) so the
2549 # check reflects what the group player itself believes — for
2550 # native/fake control the group's `power()` method sets
2551 # `_attr_powered` directly. `state.powered` routes through
2552 # `__final_power_state` which may return None for power_control
2553 # == NONE even though the group is actively capturing members.
2554 powered = group_player.powered
2555 if powered is False:
2556 # explicit power-off (fake or native) - never captures members
2557 continue
2558 if powered is not True and not group_player.is_active_session:
2559 # no explicit power-on and no captured session - group is dormant,
2560 # configured members are free to be controlled individually
2561 continue
2562 if self.player_id in group_player.state.group_members:
2563 return group_player.player_id
2564 return None
2565
2566 @cached_property
2567 @final
2568 def __final_current_media(self) -> PlayerMedia | None:
2569 """Return the FINAL current media for the player."""
2570 # if the player is grouped/synced, use the current_media of the group/parent player
2571 if parent_player_id := (self.__final_active_group or self.__final_synced_to):
2572 if parent_player_id != self.player_id and (
2573 parent_player := self.mass.players.get_player(parent_player_id)
2574 ):
2575 return parent_player.state.current_media
2576 return None # if parent player not found, return None for current media
2577 # if this is a protocol player, use the current_media of the parent player
2578 if self.type == PlayerType.PROTOCOL and self.__attr_protocol_parent_id:
2579 if parent_player := self.mass.players.get_player(self.__attr_protocol_parent_id):
2580 return parent_player.state.current_media
2581 # if MA queue is active, return those details
2582 active_source = self.__final_active_source
2583 active_queue: PlayerQueue | None = None
2584 if not active_queue and active_source:
2585 active_queue = self.mass.player_queues.get(active_source)
2586 if not active_queue and self.active_source is None:
2587 active_queue = self.mass.player_queues.get(self.player_id)
2588 if active_queue and (current_item := active_queue.current_item):
2589 item_image_url = (
2590 # the image format needs to be 512x512 jpeg for maximum compatibility with players
2591 self.mass.metadata.get_image_url(current_item.image, size=512)
2592 if current_item.image
2593 else None
2594 )
2595 if current_item.streamdetails and (
2596 stream_metadata := current_item.streamdetails.stream_metadata
2597 ):
2598 # handle stream metadata in streamdetails (e.g. for radio stream)
2599 image_url = stream_metadata.image_url or item_image_url
2600 elapsed_time, elapsed_time_last_updated = _resolve_position(
2601 stream_metadata.elapsed_time,
2602 stream_metadata.elapsed_time_last_updated,
2603 active_queue.elapsed_time,
2604 active_queue.elapsed_time_last_updated,
2605 )
2606 return PlayerMedia(
2607 uri=current_item.uri,
2608 media_type=current_item.media_type,
2609 title=stream_metadata.title or current_item.name,
2610 artist=stream_metadata.artist,
2611 album=stream_metadata.album or stream_metadata.description or current_item.name,
2612 image_url=image_url,
2613 palette=self._resolved_palette(image_url),
2614 duration=stream_metadata.duration or current_item.duration,
2615 source_id=active_queue.queue_id,
2616 queue_item_id=current_item.queue_item_id,
2617 elapsed_time=elapsed_time,
2618 elapsed_time_last_updated=elapsed_time_last_updated,
2619 )
2620 if media_item := current_item.media_item:
2621 # normal media item
2622 # we use getattr here to avoid issues with different media item types
2623 version = getattr(media_item, "version", None)
2624 album = getattr(media_item, "album", None)
2625 podcast = getattr(media_item, "podcast", None)
2626 metadata = getattr(media_item, "metadata", None)
2627 description = getattr(metadata, "description", None) if metadata else None
2628 if description:
2629 # descriptions may contain HTML markup; the OSD shows plain text
2630 description = html_to_markdown(description)
2631 image_url = (
2632 self.mass.metadata.get_image_url(current_item.media_item.image, size=512)
2633 or item_image_url
2634 if current_item.media_item.image
2635 else item_image_url
2636 )
2637 return PlayerMedia(
2638 uri=str(media_item.uri),
2639 media_type=media_item.media_type,
2640 title=f"{media_item.name} ({version})" if version else media_item.name,
2641 artist=getattr(media_item, "artist_str", None),
2642 album=album.name if album else podcast.name if podcast else description,
2643 album_artist=getattr(album, "artist_str", None),
2644 image_url=image_url,
2645 palette=self._resolved_palette(image_url),
2646 duration=media_item.duration,
2647 source_id=active_queue.queue_id,
2648 queue_item_id=current_item.queue_item_id,
2649 elapsed_time=int(active_queue.elapsed_time),
2650 elapsed_time_last_updated=active_queue.elapsed_time_last_updated,
2651 )
2652
2653 # fallback to basic current item details
2654 return PlayerMedia(
2655 uri=current_item.uri,
2656 media_type=current_item.media_type,
2657 title=current_item.name,
2658 image_url=item_image_url,
2659 palette=self._resolved_palette(item_image_url),
2660 duration=current_item.duration,
2661 source_id=active_queue.queue_id,
2662 queue_item_id=current_item.queue_item_id,
2663 elapsed_time=int(active_queue.elapsed_time),
2664 elapsed_time_last_updated=active_queue.elapsed_time_last_updated,
2665 )
2666 if active_queue:
2667 # queue is active but no current item
2668 return None
2669 # return native current media if no group/queue is active
2670 if self.current_media:
2671 image_url = self.current_media.image_url
2672 elapsed_time, elapsed_time_last_updated = _resolve_position(
2673 self.current_media.elapsed_time,
2674 self.current_media.elapsed_time_last_updated,
2675 self.elapsed_time,
2676 self.elapsed_time_last_updated,
2677 )
2678 return PlayerMedia(
2679 uri=self.current_media.uri,
2680 media_type=self.current_media.media_type,
2681 title=self.current_media.title,
2682 artist=self.current_media.artist,
2683 album=self.current_media.album,
2684 image_url=image_url,
2685 palette=self._resolved_palette(image_url),
2686 duration=self.current_media.duration,
2687 source_id=self.current_media.source_id or active_source,
2688 queue_item_id=self.current_media.queue_item_id,
2689 elapsed_time=elapsed_time,
2690 elapsed_time_last_updated=elapsed_time_last_updated,
2691 )
2692 return None
2693
2694 def _resolved_palette(self, image_url: str | None) -> MediaItemPalette | None:
2695 """Return the carried palette if it matches image_url, else None."""
2696 if image_url and image_url == self._attr_current_palette_url:
2697 return self._attr_current_palette
2698 return None
2699
2700 @cached_property
2701 @final
2702 def __final_source_list(self) -> UniqueList[PlayerSource]:
2703 """Return the FINAL source list for the player."""
2704 sources = UniqueList(self.source_list)
2705 if self.type == PlayerType.PROTOCOL:
2706 return sources
2707 # always ensure the Music Assistant Queue is in the source list
2708 mass_source = next((x for x in sources if x.id == self.player_id), None)
2709 if mass_source is None:
2710 # if the MA queue is not in the source list, add it.
2711 # The capability flags reflect what the queue can actually do right now, so clients can
2712 # grey out controls instead of issuing commands that can only fail: an empty queue has
2713 # nothing to play, seek or skip through, and a queue that played to its end can only be
2714 # started over, with nothing left to seek within or skip to.
2715 queue = self.mass.player_queues.get(self.player_id)
2716 queue_has_items = bool(queue and queue.items)
2717 queue_running = queue_has_items and not (queue and queue.ended)
2718 mass_source = PlayerSource(
2719 id=self.player_id,
2720 name="Music Assistant Queue",
2721 passive=False,
2722 can_play_pause=queue_has_items,
2723 can_seek=queue_running,
2724 can_next_previous=queue_running,
2725 )
2726 sources.append(mass_source)
2727 return sources
2728
2729 @cached_property
2730 @final
2731 def __final_group_members(self) -> list[str]:
2732 """Return the FINAL group members of this player."""
2733 if self.__final_synced_to:
2734 # If player is synced to another player, it has no group members itself
2735 return []
2736
2737 # Start by translating native group_members to visible player IDs
2738 # This handles cases where a native player (e.g., native AirPlay) has grouped
2739 # protocol players (e.g., Sonos AirPlay protocol players) that need translation
2740 members: list[str] = []
2741 if self.type == PlayerType.PROTOCOL:
2742 # protocol players use their own group members without translation
2743 members.extend(self.group_members)
2744 else:
2745 translated_members = self._translate_protocol_ids_to_visible(set(self.group_members))
2746 for member in translated_members:
2747 if member.player_id not in members:
2748 members.append(member.player_id)
2749
2750 # If there's an active linked protocol, include its group members (translated)
2751 if self.__attr_active_output_protocol and self.__attr_active_output_protocol != "native":
2752 if protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol):
2753 # Translate protocol player IDs to visible player IDs
2754 protocol_members = self._translate_protocol_ids_to_visible(
2755 set(protocol_player.group_members)
2756 )
2757 for member in protocol_members:
2758 if member.player_id not in members:
2759 members.append(member.player_id)
2760
2761 if self.type != PlayerType.GROUP:
2762 # Ensure the player_id is first in the group_members list
2763 if len(members) > 0 and members[0] != self.player_id:
2764 members = [self.player_id, *[m for m in members if m != self.player_id]]
2765 # If the only member is self, return empty list
2766 if members == [self.player_id]:
2767 return []
2768 return members
2769
2770 @cached_property
2771 @final
2772 def __final_synced_to(self) -> str | None:
2773 """
2774 Return the FINAL synced_to state.
2775
2776 This checks both native sync state and protocol player sync state,
2777 translating protocol player IDs to visible player IDs.
2778 """
2779 # First check the native synced_to from the property
2780 if native_synced_to := self.synced_to:
2781 if sync_parent := self.mass.players.get_player(native_synced_to):
2782 return sync_parent.protocol_parent_id or sync_parent.player_id
2783
2784 return native_synced_to
2785 # check if any of the linked protocol players are synced,
2786 # and if so, return the visible player they are synced to
2787 for linked in self.__attr_linked_protocols:
2788 if not (protocol_player := self.mass.players.get_player(linked.output_protocol_id)):
2789 continue
2790 if protocol_player.synced_to:
2791 # Protocol player is synced, translate to visible player
2792 if proto_sync_parent := self.mass.players.get_player(protocol_player.synced_to):
2793 if proto_sync_parent.type != PlayerType.PROTOCOL:
2794 # Sync parent is already a visible player (e.g., native AirPlay player)
2795 return proto_sync_parent.player_id
2796 if proto_sync_parent.protocol_parent_id and (
2797 parent := self.mass.players.get_player(proto_sync_parent.protocol_parent_id)
2798 ):
2799 # Sync parent is a protocol player, return its visible parent
2800 return parent.player_id
2801
2802 return None
2803
2804 @cached_property
2805 @final
2806 def __final_supported_features(self) -> set[PlayerFeature]:
2807 """Return the FINAL supported features based supported output protocol(s)."""
2808 base_features = self.supported_features.copy()
2809 if self.__attr_active_output_protocol and self.__attr_active_output_protocol != "native":
2810 # Active linked protocol: add from that specific protocol
2811 if protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol):
2812 for feature in protocol_player.supported_features:
2813 if feature in ACTIVE_PROTOCOL_FEATURES:
2814 base_features.add(feature)
2815 # Append (allowed features) from all linked protocols
2816 for linked in self.__attr_linked_protocols:
2817 if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
2818 for feature in protocol_player.supported_features:
2819 if feature in PROTOCOL_FEATURES:
2820 base_features.add(feature)
2821 if self.power_control != PLAYER_CONTROL_NONE:
2822 base_features.add(PlayerFeature.POWER)
2823 else:
2824 base_features.discard(PlayerFeature.POWER)
2825 if self.volume_control != PLAYER_CONTROL_NONE:
2826 base_features.add(PlayerFeature.VOLUME_SET)
2827 else:
2828 base_features.discard(PlayerFeature.VOLUME_SET)
2829 if self.mute_control != PLAYER_CONTROL_NONE:
2830 base_features.add(PlayerFeature.VOLUME_MUTE)
2831 else:
2832 base_features.discard(PlayerFeature.VOLUME_MUTE)
2833 if sum(1 for s in self.__final_source_list if not s.passive) >= 2:
2834 base_features.add(PlayerFeature.SELECT_SOURCE)
2835 return base_features
2836
2837 @cached_property
2838 @final
2839 def __final_can_group_with(self) -> set[str]:
2840 """
2841 Return the FINAL set of player id's this player can group with.
2842
2843 This is a convenience property which calculates the final can_group_with set
2844 based on any linked protocol players and current player/grouped state.
2845
2846 If player is synced to a native parent: return empty set (already grouped).
2847 If player is synced to a protocol: can still group with other players.
2848 If no active linked protocol: return can_group_with from all active output protocols.
2849 If active linked protocol: return native can_group_with + active protocol's.
2850
2851 All protocol player IDs are translated to their visible parent player IDs.
2852 """
2853
2854 def _should_include_player(player: Player) -> bool:
2855 """Check if a player should be included in the can-group-with set."""
2856 if not player.available:
2857 return False
2858 if player.player_id == self.player_id:
2859 return False # Don't include self
2860 # Don't include (playing) players that have group members (they are group leaders)
2861 if ( # noqa: SIM103
2862 player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2863 and player.group_members
2864 ):
2865 return False
2866 return True
2867
2868 if self.__final_synced_to:
2869 # player is already synced/grouped, cannot group with others
2870 return set()
2871
2872 expanded_can_group_with = self._expand_can_group_with()
2873 # Scenario 1: Player is a protocol player - just return the (expanded) result
2874 if self.type == PlayerType.PROTOCOL:
2875 return {x.player_id for x in expanded_can_group_with}
2876
2877 result: set[str] = set()
2878 # always start with the native can_group_with options (expanded from provider instance IDs)
2879 # NOTE we need to translate protocol player IDs to visible player IDs here as well,
2880 # to cover cases where a native player (e.g., native AirPlay) has grouped protocol players
2881 # (e.g., Sonos AirPlay protocol players)
2882 for player in expanded_can_group_with:
2883 if player.type == PlayerType.PROTOCOL:
2884 if not player.protocol_parent_id:
2885 continue
2886 parent_player = self.mass.players.get_player(player.protocol_parent_id)
2887 if not parent_player or not _should_include_player(parent_player):
2888 continue
2889 result.add(parent_player.player_id)
2890 elif _should_include_player(player):
2891 result.add(player.player_id)
2892
2893 # Scenario 2: External source is active - don't include protocol-based grouping
2894 # When an external source (e.g., Spotify Connect, TV) is active, grouping via
2895 # protocols (AirPlay, Sendspin, etc.) wouldn't work - only native grouping is available.
2896 if self._has_external_source_active():
2897 return result
2898
2899 # Translate can_group_with from active linked protocol(s) and add to result
2900 for linked in self.__attr_linked_protocols:
2901 if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
2902 for player in self._translate_protocol_ids_to_visible(
2903 protocol_player.state.can_group_with
2904 ):
2905 if not _should_include_player(player):
2906 continue
2907 result.add(player.player_id)
2908 return result
2909
2910 @cached_property
2911 @final
2912 def __final_active_source(self) -> str | None:
2913 """
2914 Calculate the final active source based on any group memberships, source plugins etc.
2915
2916 This is rather complicated as we need to account for various scenarios like:
2917 - player is grouped/synced: use the active source of the group/parent player
2918 - protocol player: prefer the active source of the parent player
2919 - plugin source active: return the active plugin source
2920 - linked protocol active: prefer the active source of the linked protocol player
2921 - a protocol player may report an active source that is actually from an
2922 active output protocol (e.g. AirPlay)
2923 - a protocol player that has a 3rd party source active
2924 """
2925 # if the player is grouped/synced, use the active source of the group/parent player
2926 if parent_player_id := (self.__final_synced_to or self.__final_active_group):
2927 if parent_player := self.mass.players.get_player(parent_player_id):
2928 return parent_player.state.active_source
2929 return None # should not happen but just in case
2930
2931 # if this is a protocol player, prefer the active source of the parent player
2932 # a protocol player can not have an active source on its own.
2933 if (
2934 self.type == PlayerType.PROTOCOL
2935 and self.protocol_parent_id
2936 and (parent_player := self.mass.players.get_player(self.protocol_parent_id))
2937 ):
2938 return parent_player.state.active_source
2939
2940 # always prefer active MA source but add a guard to detect if player is really playing
2941 # something different, such as a line-in or TV input, we use an explicit list here
2942 # because many players do not accurately report the active_source
2943 # this way, for the obvious cases, we can detect a source "takeover"
2944 if self.__active_mass_source and (
2945 not self.active_source or self.active_source.lower() not in EXTERNAL_SOURCES
2946 ):
2947 return self.__active_mass_source
2948
2949 # active source as reported by the player itself
2950 if (
2951 self.active_source
2952 and self.active_source != self.player_id
2953 and self.playback_state != PlaybackState.IDLE
2954 # If an output protocol is active, we simply overrule the active source of the player.
2955 # Trying to handle this differently is a hot mess and leads to all kinds of edge cases,
2956 # because many players do not report the active source correctly, especially not when
2957 # an output protocol is active.
2958 and self.active_output_protocol in (None, "native")
2959 ):
2960 return self.active_source
2961
2962 # return the (last) known MA source - fallback to player's own queue source if none
2963 return self.__active_mass_source or self.player_id
2964
2965 @final
2966 def _translate_protocol_ids_to_visible(self, player_ids: set[str]) -> set[Player]:
2967 """
2968 Translate protocol player IDs to their visible parent players.
2969
2970 Protocol players are hidden and users interact with visible players
2971 (native or universal). This method translates protocol player IDs
2972 back to the visible (parent) players.
2973
2974 :param player_ids: Set of player IDs.
2975 :return: Set of visible players.
2976 """
2977 result: set[Player] = set()
2978 if not player_ids:
2979 return result
2980 for player_id in player_ids:
2981 target_player = self.mass.players.get_player(player_id)
2982 if not target_player:
2983 continue
2984 if target_player.type != PlayerType.PROTOCOL:
2985 # Non-protocol player is already visible - include directly
2986 result.add(target_player)
2987 continue
2988 # This is a protocol player - find its visible parent
2989 if not target_player.protocol_parent_id:
2990 continue
2991 parent_player = self.mass.players.get_player(target_player.protocol_parent_id)
2992 if not parent_player:
2993 continue
2994 result.add(parent_player)
2995 return result
2996
2997 @final
2998 def _has_external_source_active(self) -> bool:
2999 """
3000 Check if an external (non-MA-managed) source is currently active.
3001
3002 External sources include things like Spotify Connect, TV input, etc.
3003 When an external source is active, protocol-based grouping is not available.
3004
3005 :return: True if an external source is active, False otherwise.
3006 """
3007 active_source = self.__final_active_source
3008 if active_source is None:
3009 return False
3010
3011 # Player's own ID means MA queue is (or was) active
3012 if active_source == self.player_id:
3013 return False
3014
3015 # If it's a known queue ID it's MA-managed; anything else is external
3016 # (line-in, TV input, etc.)
3017 return self.mass.player_queues.get(active_source) is None
3018
3019 @final
3020 def _expand_can_group_with(self) -> set[Player]:
3021 """
3022 Expand the 'can-group-with' to include all players from provider instance IDs.
3023
3024 This method expands any provider instance IDs (e.g., "airplay", "chromecast")
3025 in the group members to all (available) players of that provider
3026
3027 :return: Set of available players in the can-group-with.
3028 """
3029 result = set()
3030
3031 for member_id in self.can_group_with:
3032 if player := self.mass.players.get_player(member_id):
3033 result.add(player)
3034 continue # already a player ID
3035 # Check if member_id is a provider instance ID
3036 if provider := self.mass.get_provider(member_id):
3037 for player in self.mass.players.iter_players(
3038 return_unavailable=False, # Only include available players
3039 provider_filter=provider.instance_id,
3040 return_protocol_players=True,
3041 ):
3042 result.add(player)
3043 return result
3044
3045 # The id of the (last) active mass source.
3046 # This is to keep track of the last active MA source for the player,
3047 # so we can restore it when needed (e.g. after switching to a plugin source).
3048 __active_mass_source: str | None = None
3049
3050 @final
3051 def set_active_mass_source(self, value: str | None) -> None:
3052 """
3053 Set the id of the (last) active mass source.
3054
3055 This is to keep track of the last active MA source for the player,
3056 so we can restore it when needed (e.g. after switching to a plugin source).
3057 """
3058 self.mass.cancel_timer(f"set_mass_source_{self.player_id}")
3059 self.__active_mass_source = value
3060 self.update_state()
3061
3062 __sleep_timer_expires_at: float | None = None
3063
3064 @final
3065 def set_sleep_timer_expires_at(self, value: float | None) -> None:
3066 """
3067 Set the unix (utc) timestamp at which the active sleep timer stops playback.
3068
3069 :param value: The expiry timestamp, or None to clear the sleep timer.
3070 """
3071 self.__sleep_timer_expires_at = value
3072
3073 @property
3074 @final
3075 def sleep_timer_expires_at(self) -> float | None:
3076 """Return the unix (utc) timestamp at which the active sleep timer stops playback."""
3077 return self.__sleep_timer_expires_at
3078
3079 __stop_called: bool = False
3080
3081 @final
3082 def mark_stop_called(self) -> None:
3083 """Mark that the STOP command was called on the player."""
3084 self.__stop_called = True
3085
3086 @property
3087 @final
3088 def stop_called(self) -> bool:
3089 """
3090 Return True if the STOP command was called on the player.
3091
3092 This is used to differentiate between a user-initiated stop
3093 and a natural end of playback (e.g. end of track/queue).
3094 mainly for debugging/logging purposes by the streams controller.
3095 """
3096 return self.__stop_called
3097
3098 def __hash__(self) -> int:
3099 """Return a hash of the Player."""
3100 return hash(self.player_id)
3101
3102 def __str__(self) -> str:
3103 """Return a string representation of the Player."""
3104 return f"Player {self.name} ({self.player_id})"
3105
3106 def __repr__(self) -> str:
3107 """Return a string representation of the Player."""
3108 return f"<Player name={self.name} id={self.player_id} available={self.available}>"
3109
3110 def __eq__(self, other: object) -> bool:
3111 """Check equality of two Player objects."""
3112 if not isinstance(other, Player):
3113 return False
3114 return self.player_id == other.player_id
3115
3116 def __ne__(self, other: object) -> bool:
3117 """Check inequality of two Player objects."""
3118 return not self.__eq__(other)
3119
3120
3121__all__ = [
3122 # explicitly re-export the models we imported from the models package,
3123 # for convenience reasons
3124 "EXTRA_ATTRIBUTES_TYPES",
3125 "DeviceInfo",
3126 "Player",
3127 "PlayerMedia",
3128 "PlayerSource",
3129 "PlayerState",
3130]
3131