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