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