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