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