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