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