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