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