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