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