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