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