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