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