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