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