/
/
/
1"""AirPlay Player implementations."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, cast
8
9from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
10from music_assistant_models.enums import (
11 ConfigEntryType,
12 IdentifierType,
13 PlaybackState,
14 PlayerFeature,
15 PlayerType,
16)
17
18from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry
19from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
20
21from .constants import (
22 AIRPLAY_DISCOVERY_TYPE,
23 AIRPLAY_FLOW_PCM_FORMAT,
24 CACHE_CATEGORY_PREV_VOLUME,
25 CONF_ACTION_FINISH_PAIRING,
26 CONF_ACTION_RESET_PAIRING,
27 CONF_ACTION_START_PAIRING,
28 CONF_AIRPLAY_CREDENTIALS,
29 CONF_AIRPLAY_PROTOCOL,
30 CONF_ALAC_ENCODE,
31 CONF_ENCRYPTION,
32 CONF_IGNORE_VOLUME,
33 CONF_PAIRING_PIN,
34 CONF_PASSWORD,
35 CONF_RAOP_CREDENTIALS,
36 FALLBACK_VOLUME,
37 RAOP_DISCOVERY_TYPE,
38 StreamingProtocol,
39)
40from .helpers import (
41 get_primary_ip_address_from_zeroconf,
42 is_airplay2_preferred_model,
43 is_apple_device,
44 is_broken_airplay_model,
45 player_id_to_mac_address,
46)
47from .stream_session import AirPlayStreamSession
48
49if TYPE_CHECKING:
50 from zeroconf.asyncio import AsyncServiceInfo
51
52 from .pairing import AirPlayPairing
53 from .protocols._protocol import AirPlayProtocol
54 from .protocols.airplay2 import AirPlay2Stream
55 from .protocols.raop import RaopStream
56 from .provider import AirPlayProvider
57
58
59BROKEN_AIRPLAY_WARN = ConfigEntry(
60 key="BROKEN_AIRPLAY",
61 type=ConfigEntryType.ALERT,
62 default_value=None,
63 required=False,
64 label="This player is known to have broken AirPlay support. "
65 "Playback may fail or simply be silent. "
66 "There is no workaround for this issue at the moment. \n"
67 "If you already enforced AirPlay 2 on the player and it remains silent, "
68 "this is one of the known broken models. Only remedy is to nag the manufacturer for a fix.",
69)
70
71
72class AirPlayPlayer(Player):
73 """AirPlay Player implementation."""
74
75 def __init__(
76 self,
77 provider: AirPlayProvider,
78 player_id: str,
79 raop_discovery_info: AsyncServiceInfo | None,
80 airplay_discovery_info: AsyncServiceInfo | None,
81 address: str,
82 display_name: str,
83 manufacturer: str,
84 model: str,
85 initial_volume: int = FALLBACK_VOLUME,
86 ) -> None:
87 """Initialize AirPlayPlayer."""
88 super().__init__(provider, player_id)
89 self.raop_discovery_info = raop_discovery_info
90 self.airplay_discovery_info = airplay_discovery_info
91 self.address = address
92 self.stream: RaopStream | AirPlay2Stream | None = None
93 self.last_command_sent = 0.0
94 self._lock = asyncio.Lock()
95 self._active_pairing: AirPlayPairing | None = None
96 self._transitioning = False # Set during stream replacement to ignore stale DACP messages
97 # Set (static) player attributes
98 self._attr_name = display_name
99 self._attr_available = True
100 mac_address = player_id_to_mac_address(player_id)
101 self._attr_device_info = DeviceInfo(
102 model=model,
103 manufacturer=manufacturer,
104 )
105 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
106 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
107 self._attr_supported_features = {
108 PlayerFeature.PLAY_MEDIA,
109 PlayerFeature.PAUSE,
110 PlayerFeature.SET_MEMBERS,
111 PlayerFeature.MULTI_DEVICE_DSP,
112 PlayerFeature.VOLUME_SET,
113 }
114 self._attr_volume_level = initial_volume
115 self._attr_can_group_with = {provider.instance_id}
116 self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
117
118 # Set player type based on manufacturer:
119 # - Apple devices (HomePod, Apple TV, Mac) have native AirPlay support -> PLAYER
120 # - Non-Apple devices are generic AirPlay receivers -> PROTOCOL (wrapped in UniversalPlayer)
121 if is_apple_device(manufacturer):
122 self._attr_type = PlayerType.PLAYER
123 else:
124 self._attr_type = PlayerType.PROTOCOL
125
126 @property
127 def protocol(self) -> StreamingProtocol:
128 """Get the streaming protocol to use/prefer for this player."""
129 preferred_option = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL))
130 return self._get_protocol_for_config_value(preferred_option)
131
132 @property
133 def available(self) -> bool:
134 """Return if the player is currently available."""
135 if self._requires_pairing():
136 # check if we have credentials stored for the current protocol
137 creds_key = self._get_credentials_key(self.protocol)
138 if not self.config.get_value(creds_key):
139 return False
140 return super().available
141
142 @property
143 def requires_flow_mode(self) -> bool:
144 """Return if the player requires flow mode."""
145 return True
146
147 async def get_config_entries(
148 self,
149 action: str | None = None,
150 values: dict[str, ConfigValueType] | None = None,
151 ) -> list[ConfigEntry]:
152 """Return all (provider/player specific) Config Entries for the given player (if any)."""
153 base_entries: list[ConfigEntry] = []
154 require_pairing = self._requires_pairing()
155
156 # Handle pairing actions
157 if action and require_pairing:
158 await self._handle_pairing_action(action=action, values=values)
159
160 # Add pairing config entries for Apple TV and macOS devices
161 if require_pairing:
162 base_entries = [*self._get_pairing_config_entries(values)]
163
164 # Regular AirPlay config entries
165 base_entries += [
166 ConfigEntry(
167 key=CONF_AIRPLAY_PROTOCOL,
168 type=ConfigEntryType.INTEGER,
169 required=False,
170 label="AirPlay protocol version to use for streaming",
171 description="AirPlay version 1 protocol uses RAOP.\n"
172 "AirPlay version 2 is an extension of RAOP.\n"
173 "Some newer devices do not fully support RAOP and "
174 "will only work with AirPlay version 2, "
175 "while older devices may only support RAOP.\n\n"
176 "In most cases the default automatic selection will work fine.",
177 options=[
178 ConfigValueOption("Automatically select", 0),
179 ConfigValueOption("Prefer AirPlay 1 (RAOP)", StreamingProtocol.RAOP.value),
180 ConfigValueOption("Prefer AirPlay 2", StreamingProtocol.AIRPLAY2.value),
181 ],
182 default_value=0,
183 category="protocol_generic",
184 ),
185 ConfigEntry(
186 key=CONF_ENCRYPTION,
187 type=ConfigEntryType.BOOLEAN,
188 default_value=True,
189 label="Enable encryption",
190 description="Enable encrypted communication with the player, "
191 "some (3rd party) players require this to be disabled.",
192 depends_on=CONF_AIRPLAY_PROTOCOL,
193 depends_on_value=StreamingProtocol.RAOP.value,
194 hidden=self.protocol != StreamingProtocol.RAOP,
195 category="protocol_generic",
196 advanced=True,
197 ),
198 ConfigEntry(
199 key=CONF_ALAC_ENCODE,
200 type=ConfigEntryType.BOOLEAN,
201 default_value=True,
202 label="Enable compression",
203 description="Save some network bandwidth by sending the audio as "
204 "(lossless) ALAC at the cost of a bit of CPU.",
205 depends_on=CONF_AIRPLAY_PROTOCOL,
206 depends_on_value=StreamingProtocol.RAOP.value,
207 hidden=self.protocol != StreamingProtocol.RAOP,
208 category="protocol_generic",
209 advanced=True,
210 ),
211 CONF_ENTRY_SYNC_ADJUST,
212 ConfigEntry(
213 key=CONF_PASSWORD,
214 type=ConfigEntryType.SECURE_STRING,
215 default_value=None,
216 required=False,
217 label="Device password",
218 description="Some devices require a password to connect/play.",
219 category="protocol_generic",
220 advanced=True,
221 ),
222 # airplay has fixed sample rate/bit depth so make this config entry static and hidden
223 create_sample_rates_config_entry(
224 supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
225 ),
226 ConfigEntry(
227 key=CONF_IGNORE_VOLUME,
228 type=ConfigEntryType.BOOLEAN,
229 default_value=False,
230 label="Ignore volume reports sent by the device itself",
231 description=(
232 "The AirPlay protocol allows devices to report their own volume "
233 "level. \n"
234 "For some devices this is not reliable and can cause unexpected "
235 "volume changes. \n"
236 "Enable this option to ignore these reports."
237 ),
238 category="protocol_generic",
239 # TODO: remove depends_on when DACP support is added for AirPlay2
240 depends_on=CONF_AIRPLAY_PROTOCOL,
241 depends_on_value=StreamingProtocol.RAOP.value,
242 hidden=self.protocol != StreamingProtocol.RAOP,
243 advanced=True,
244 ),
245 ]
246
247 if is_broken_airplay_model(self.device_info.manufacturer, self.device_info.model):
248 base_entries.insert(-1, BROKEN_AIRPLAY_WARN)
249
250 return base_entries
251
252 def _requires_pairing(self) -> bool:
253 """Check if this device requires pairing (Apple TV or macOS)."""
254 if self.device_info.manufacturer.lower() != "apple":
255 return False
256
257 model = self.device_info.model
258 # Apple TV devices
259 if "appletv" in model.lower() or "apple tv" in model.lower():
260 return True
261 # Mac devices (including iMac, MacBook, Mac mini, Mac Pro, Mac Studio)
262 return model.startswith(("Mac", "iMac"))
263
264 def _get_credentials_key(self, protocol: StreamingProtocol) -> str:
265 """Get the config key for credentials for given protocol."""
266 if protocol == StreamingProtocol.RAOP:
267 return CONF_RAOP_CREDENTIALS
268 return CONF_AIRPLAY_CREDENTIALS
269
270 def _get_protocol_for_config_value(self, config_option: int) -> StreamingProtocol:
271 if config_option == StreamingProtocol.AIRPLAY2 and self.airplay_discovery_info:
272 return StreamingProtocol.AIRPLAY2
273 if config_option == StreamingProtocol.RAOP and self.raop_discovery_info:
274 return StreamingProtocol.RAOP
275 # automatic selection
276 if self.airplay_discovery_info and is_airplay2_preferred_model(
277 self.device_info.manufacturer, self.device_info.model
278 ):
279 return StreamingProtocol.AIRPLAY2
280 return StreamingProtocol.RAOP
281
282 def _get_pairing_config_entries(
283 self, values: dict[str, ConfigValueType] | None
284 ) -> list[ConfigEntry]:
285 """
286 Return pairing config entries for Apple TV and macOS devices.
287
288 Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
289 """
290 entries: list[ConfigEntry] = []
291
292 # Determine protocol name for UI
293 conf_protocol: int = 0
294 if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
295 conf_protocol = cast("int", val)
296 else:
297 conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
298 protocol = self._get_protocol_for_config_value(conf_protocol)
299 protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
300 protocol_key = (
301 CONF_RAOP_CREDENTIALS
302 if protocol == StreamingProtocol.RAOP
303 else CONF_AIRPLAY_CREDENTIALS
304 )
305 has_creds_for_current_protocol = (
306 values.get(protocol_key) if values else self.config.get_value(protocol_key)
307 )
308
309 if not has_creds_for_current_protocol:
310 # If pairing was started, show PIN entry
311 if self._active_pairing and self._active_pairing.is_pairing:
312 entries.append(
313 ConfigEntry(
314 key=CONF_PAIRING_PIN,
315 type=ConfigEntryType.STRING,
316 label="Enter the 4-digit PIN shown on the device",
317 required=True,
318 category="protocol_generic",
319 )
320 )
321 entries.append(
322 ConfigEntry(
323 key=CONF_ACTION_FINISH_PAIRING,
324 type=ConfigEntryType.ACTION,
325 label=f"Complete {protocol_name} pairing with the PIN",
326 action=CONF_ACTION_FINISH_PAIRING,
327 category="protocol_generic",
328 )
329 )
330 else:
331 # Show pairing instructions and start button
332 entries.append(
333 ConfigEntry(
334 key="pairing_instructions",
335 type=ConfigEntryType.LABEL,
336 label=(
337 f"This device requires {protocol_name} pairing before it can be used. "
338 "Click the button below to start the pairing process."
339 ),
340 category="protocol_generic",
341 )
342 )
343 entries.append(
344 ConfigEntry(
345 key=CONF_ACTION_START_PAIRING,
346 type=ConfigEntryType.ACTION,
347 label=f"Start {protocol_name} pairing",
348 action=CONF_ACTION_START_PAIRING,
349 category="protocol_generic",
350 )
351 )
352 else:
353 # Show paired status
354 entries.append(
355 ConfigEntry(
356 key="pairing_status",
357 type=ConfigEntryType.LABEL,
358 label=f"Device is paired ({protocol_name}) and ready to use.",
359 category="protocol_generic",
360 )
361 )
362 # Add reset pairing button
363 entries.append(
364 ConfigEntry(
365 key=CONF_ACTION_RESET_PAIRING,
366 type=ConfigEntryType.ACTION,
367 label=f"Reset {protocol_name} pairing",
368 action=CONF_ACTION_RESET_PAIRING,
369 category="protocol_generic",
370 )
371 )
372
373 # Store credentials (hidden from UI)
374 for protocol in (StreamingProtocol.RAOP, StreamingProtocol.AIRPLAY2):
375 conf_key = self._get_credentials_key(protocol)
376 entries.append(
377 ConfigEntry(
378 key=conf_key,
379 type=ConfigEntryType.SECURE_STRING,
380 label=conf_key,
381 default_value=None,
382 value=values.get(conf_key) if values else None,
383 required=False,
384 hidden=True,
385 category="protocol_generic",
386 )
387 )
388 return entries
389
390 async def _handle_pairing_action(
391 self, action: str, values: dict[str, ConfigValueType] | None
392 ) -> None:
393 """
394 Handle pairing actions.
395
396 Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
397 Both produce credentials compatible with cliap2/cliraop respectively.
398 """
399 conf_protocol: int = 0
400 if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
401 conf_protocol = cast("int", val)
402 else:
403 conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
404 protocol = self._get_protocol_for_config_value(conf_protocol)
405 protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
406
407 if action == CONF_ACTION_START_PAIRING:
408 if self._active_pairing and self._active_pairing.is_pairing:
409 self.logger.warning("Pairing process already in progress for %s", self.display_name)
410 return
411
412 self.logger.info("Starting %s pairing for %s", protocol_name, self.display_name)
413
414 from .pairing import AirPlayPairing # noqa: PLC0415
415
416 # Determine port based on protocol
417 # Note: For Apple devices, pairing always happens on the AirPlay port (7000)
418 # even when streaming will use RAOP. The RAOP port (5000) is only for streaming.
419 port: int | None = None
420 if self.airplay_discovery_info:
421 port = self.airplay_discovery_info.port or 7000
422 elif self.raop_discovery_info:
423 # Fallback for devices without AirPlay service
424 port = self.raop_discovery_info.port or 5000
425 # Get the DACP ID from the provider - must match what cliap2 uses
426 provider = cast("AirPlayProvider", self.provider)
427 device_id = provider.dacp_id
428
429 self._active_pairing = AirPlayPairing(
430 address=self.address,
431 name=self.display_name,
432 protocol=protocol,
433 logger=self.logger,
434 port=port,
435 device_id=device_id,
436 )
437 await self._active_pairing.start_pairing()
438
439 elif action == CONF_ACTION_FINISH_PAIRING:
440 if not values:
441 return
442
443 pin = values.get(CONF_PAIRING_PIN)
444 if not pin:
445 self.logger.warning("No PIN provided for pairing")
446 return
447
448 if not self._active_pairing:
449 self.logger.warning("No active pairing session for %s", self.display_name)
450 return
451
452 credentials = await self._active_pairing.finish_pairing(pin=str(pin))
453 self._active_pairing = None
454
455 # Store credentials with the protocol-specific key
456 cred_key = self._get_credentials_key(protocol)
457 values[cred_key] = credentials
458
459 self.logger.info("Finished %s pairing for %s", protocol_name, self.display_name)
460
461 elif action == CONF_ACTION_RESET_PAIRING:
462 cred_key = self._get_credentials_key(protocol)
463 self.logger.info("Resetting %s pairing for %s", protocol_name, self.display_name)
464 if values is not None:
465 values[cred_key] = None
466
467 async def stop(self) -> None:
468 """Send STOP command to player."""
469 if self.stream and self.stream.session:
470 # forward stop to the entire stream session
471 await self.stream.session.stop()
472 self._attr_current_media = None
473 self.update_state()
474
475 async def play(self) -> None:
476 """Send PLAY (unpause) command to player."""
477 async with self._lock:
478 if self.stream and self.stream.running:
479 await self.stream.send_cli_command("ACTION=PLAY")
480
481 async def pause(self) -> None:
482 """Send PAUSE command to player."""
483 if self.group_members:
484 # pause is not supported while synced, use stop instead
485 self.logger.debug("Player is synced, using STOP instead of PAUSE")
486 await self.stop()
487 return
488
489 async with self._lock:
490 if not self.stream or not self.stream.running:
491 return
492 await self.stream.send_cli_command("ACTION=PAUSE")
493
494 async def play_media(self, media: PlayerMedia) -> None:
495 """Handle PLAY MEDIA on given player."""
496 if self.synced_to:
497 # this should not happen, but guard anyways
498 raise RuntimeError("Player is synced")
499 self._attr_current_media = media
500
501 # Always stop any existing stream
502 if self.stream and self.stream.running and self.stream.session:
503 # Set transitioning flag to ignore stale DACP messages (like prevent-playback)
504 self._transitioning = True
505 # Force stop the session (to speed up stopping)
506 await self.stream.session.stop(force=True)
507 self.stream = None
508
509 # select audio source
510 audio_source = self.mass.streams.get_stream(media, AIRPLAY_FLOW_PCM_FORMAT)
511
512 # setup StreamSession for player (and its sync childs if any)
513 sync_clients = self._get_sync_clients()
514 provider = cast("AirPlayProvider", self.provider)
515 stream_session = AirPlayStreamSession(provider, sync_clients, AIRPLAY_FLOW_PCM_FORMAT)
516 await stream_session.start(audio_source)
517 self._attr_elapsed_time = time.time() - stream_session.start_time
518 self._attr_elapsed_time_last_updated = time.time()
519 self._transitioning = False
520
521 async def volume_set(self, volume_level: int) -> None:
522 """Send VOLUME_SET command to given player."""
523 if self.stream and self.stream.running:
524 await self.stream.send_cli_command(f"VOLUME={volume_level}")
525 self._attr_volume_level = volume_level
526 self.update_state()
527 # store last state in cache
528 await self.mass.cache.set(
529 key=self.player_id,
530 data=volume_level,
531 provider=self.provider.instance_id,
532 category=CACHE_CATEGORY_PREV_VOLUME,
533 )
534
535 async def set_members(
536 self,
537 player_ids_to_add: list[str] | None = None,
538 player_ids_to_remove: list[str] | None = None,
539 ) -> None:
540 """Handle SET_MEMBERS command on the player."""
541 if self.synced_to:
542 # this should not happen, but guard anyways
543 raise RuntimeError("Player is synced, cannot set members")
544 if not player_ids_to_add and not player_ids_to_remove:
545 # nothing to do
546 return
547
548 stream_session = (
549 self.stream.session
550 if self.stream and self.stream.running and self.stream.session
551 else None
552 )
553 # handle removals first
554 if player_ids_to_remove:
555 if self.player_id in player_ids_to_remove:
556 # dissolve the entire sync group
557 if stream_session:
558 # stop the stream session if it is running
559 await stream_session.stop()
560 self._attr_group_members = []
561 self.update_state()
562 return
563
564 for child_player in self._get_sync_clients():
565 if child_player.player_id in player_ids_to_remove:
566 if stream_session:
567 await stream_session.remove_client(child_player)
568 if child_player.player_id in self._attr_group_members:
569 self._attr_group_members.remove(child_player.player_id)
570
571 # If group leader is left alone after removals, clear the group_members list
572 if (
573 self._attr_group_members
574 and len(self._attr_group_members) == 1
575 and self.player_id in self._attr_group_members
576 ):
577 self._attr_group_members = []
578
579 # handle additions
580 for player_id in player_ids_to_add or []:
581 if player_id == self.player_id or player_id in self.group_members:
582 # nothing to do: player is already part of the group
583 continue
584 child_player_to_add: AirPlayPlayer | None = cast(
585 "AirPlayPlayer | None", self.mass.players.get_player(player_id)
586 )
587 if not child_player_to_add:
588 # should not happen, but guard against it
589 continue
590 if child_player_to_add.synced_to and child_player_to_add.synced_to != self.player_id:
591 raise RuntimeError("Player is already synced to another player")
592
593 # ensure the child does not have an existing stream session active
594 if child_player_to_add := cast(
595 "AirPlayPlayer | None", self.mass.players.get_player(player_id)
596 ):
597 if (
598 child_player_to_add.playback_state == PlaybackState.PAUSED
599 and child_player_to_add.stream
600 ):
601 # Stop the paused stream to avoid a deadlock situation
602 await child_player_to_add.stream.stop()
603 if (
604 child_player_to_add.stream
605 and child_player_to_add.stream.running
606 and child_player_to_add.stream.session
607 and child_player_to_add.stream.session != stream_session
608 ):
609 await child_player_to_add.stream.session.remove_client(child_player_to_add)
610
611 # add new child to the existing stream (RAOP or AirPlay2) session (if any)
612 self._attr_group_members.append(player_id)
613 if stream_session:
614 await stream_session.add_client(child_player_to_add)
615
616 # Ensure group leader includes itself in group_members when it has members
617 # This is required for the synced_to property to work correctly
618 if self._attr_group_members and self.player_id not in self._attr_group_members:
619 self._attr_group_members.insert(0, self.player_id)
620
621 # always update the state after modifying group members
622 self.update_state()
623
624 def _on_player_media_updated(self) -> None:
625 """Handle callback when the current media of the player is updated."""
626 if not self.stream or not self.stream.running or not self.stream.session:
627 return
628 metadata = self.state.current_media
629 if not metadata:
630 return
631 progress = int(metadata.corrected_elapsed_time or 0)
632 self.mass.create_task(self.stream.send_metadata(progress, metadata))
633
634 def update_volume_from_device(self, volume: int) -> None:
635 """Update volume from device feedback."""
636 ignore_volume_report = (
637 self.config.get_value(CONF_IGNORE_VOLUME)
638 or self.device_info.manufacturer.lower() == "apple"
639 )
640
641 if ignore_volume_report:
642 return
643
644 cur_volume = self.volume_level or 0
645 if abs(cur_volume - volume) > 3 or (time.time() - self.last_command_sent) > 3:
646 self.mass.create_task(self.volume_set(volume))
647 else:
648 self._attr_volume_level = volume
649 self.update_state()
650
651 def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None:
652 """Set/update the discovery info for the player."""
653 self._attr_name = display_name
654 if discovery_info.type == AIRPLAY_DISCOVERY_TYPE:
655 self.airplay_discovery_info = discovery_info
656 elif discovery_info.type == RAOP_DISCOVERY_TYPE:
657 self.raop_discovery_info = discovery_info
658 else: # guard
659 return
660 cur_address = self.address
661 new_address = get_primary_ip_address_from_zeroconf(discovery_info)
662 if new_address is None:
663 # should always be set, but guard against None
664 return
665 if cur_address != new_address:
666 self.logger.debug("Address updated from %s to %s", cur_address, new_address)
667 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_address)
668 self.address = new_address
669 self.update_state()
670
671 def set_state_from_stream(
672 self,
673 state: PlaybackState | None = None,
674 elapsed_time: float | None = None,
675 stream: AirPlayProtocol | None = None,
676 ) -> None:
677 """Set the playback state from stream (RAOP or AirPlay2).
678
679 :param state: New playback state (or None to keep current).
680 :param elapsed_time: New elapsed time (or None to keep current).
681 :param stream: The stream instance sending this update (for validation).
682 """
683 # Ignore state updates from old/stale streams
684 if stream is not None and stream != self.stream:
685 return
686 if state is not None:
687 self._attr_playback_state = state
688 if elapsed_time is not None:
689 self._attr_elapsed_time = elapsed_time
690 self._attr_elapsed_time_last_updated = time.time()
691 self.update_state()
692
693 async def on_unload(self) -> None:
694 """Handle logic when the player is unloaded from the Player controller."""
695 await super().on_unload()
696 if self.stream:
697 # stop the stream session if it is running
698 if self.stream.running and self.stream.session:
699 self.mass.create_task(self.stream.session.stop())
700 self.stream = None
701 if self._active_pairing:
702 await self._active_pairing.close()
703 self._active_pairing = None
704
705 def _get_sync_clients(self) -> list[AirPlayPlayer]:
706 """Get all sync clients for a player."""
707 sync_clients: list[AirPlayPlayer] = []
708 # we need to return the player itself too
709 group_child_ids = {self.player_id}
710 group_child_ids.update(self.group_members)
711 for child_id in group_child_ids:
712 if client := cast("AirPlayPlayer | None", self.mass.players.get_player(child_id)):
713 sync_clients.append(client)
714 return sync_clients
715