/
/
/
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 category="airplay",
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 ),
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 category="airplay",
189 depends_on=CONF_AIRPLAY_PROTOCOL,
190 depends_on_value=StreamingProtocol.RAOP.value,
191 hidden=self.protocol != StreamingProtocol.RAOP,
192 ),
193 ConfigEntry(
194 key=CONF_ALAC_ENCODE,
195 type=ConfigEntryType.BOOLEAN,
196 default_value=True,
197 label="Enable compression",
198 description="Save some network bandwidth by sending the audio as "
199 "(lossless) ALAC at the cost of a bit of CPU.",
200 category="airplay",
201 depends_on=CONF_AIRPLAY_PROTOCOL,
202 depends_on_value=StreamingProtocol.RAOP.value,
203 hidden=self.protocol != StreamingProtocol.RAOP,
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="airplay",
214 ),
215 # airplay has fixed sample rate/bit depth so make this config entry static and hidden
216 create_sample_rates_config_entry(
217 supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
218 ),
219 ConfigEntry(
220 key=CONF_IGNORE_VOLUME,
221 type=ConfigEntryType.BOOLEAN,
222 default_value=False,
223 label="Ignore volume reports sent by the device itself",
224 description=(
225 "The AirPlay protocol allows devices to report their own volume "
226 "level. \n"
227 "For some devices this is not reliable and can cause unexpected "
228 "volume changes. \n"
229 "Enable this option to ignore these reports."
230 ),
231 category="airplay",
232 # TODO: remove depends_on when DACP support is added for AirPlay2
233 depends_on=CONF_AIRPLAY_PROTOCOL,
234 depends_on_value=StreamingProtocol.RAOP.value,
235 hidden=self.protocol != StreamingProtocol.RAOP,
236 ),
237 ]
238
239 if is_broken_airplay_model(self.device_info.manufacturer, self.device_info.model):
240 base_entries.insert(-1, BROKEN_AIRPLAY_WARN)
241
242 return base_entries
243
244 def _requires_pairing(self) -> bool:
245 """Check if this device requires pairing (Apple TV or macOS)."""
246 if self.device_info.manufacturer.lower() != "apple":
247 return False
248
249 model = self.device_info.model
250 # Apple TV devices
251 if "appletv" in model.lower() or "apple tv" in model.lower():
252 return True
253 # Mac devices (including iMac, MacBook, Mac mini, Mac Pro, Mac Studio)
254 return model.startswith(("Mac", "iMac"))
255
256 def _get_credentials_key(self, protocol: StreamingProtocol) -> str:
257 """Get the config key for credentials for given protocol."""
258 if protocol == StreamingProtocol.RAOP:
259 return CONF_RAOP_CREDENTIALS
260 return CONF_AIRPLAY_CREDENTIALS
261
262 def _get_protocol_for_config_value(self, config_option: int) -> StreamingProtocol:
263 if config_option == StreamingProtocol.AIRPLAY2 and self.airplay_discovery_info:
264 return StreamingProtocol.AIRPLAY2
265 if config_option == StreamingProtocol.RAOP and self.raop_discovery_info:
266 return StreamingProtocol.RAOP
267 # automatic selection
268 if self.airplay_discovery_info and is_airplay2_preferred_model(
269 self.device_info.manufacturer, self.device_info.model
270 ):
271 return StreamingProtocol.AIRPLAY2
272 return StreamingProtocol.RAOP
273
274 def _get_pairing_config_entries(
275 self, values: dict[str, ConfigValueType] | None
276 ) -> list[ConfigEntry]:
277 """
278 Return pairing config entries for Apple TV and macOS devices.
279
280 Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
281 """
282 entries: list[ConfigEntry] = []
283
284 # Determine protocol name for UI
285 conf_protocol: int = 0
286 if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
287 conf_protocol = cast("int", val)
288 else:
289 conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
290 protocol = self._get_protocol_for_config_value(conf_protocol)
291 protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
292 protocol_key = (
293 CONF_RAOP_CREDENTIALS
294 if protocol == StreamingProtocol.RAOP
295 else CONF_AIRPLAY_CREDENTIALS
296 )
297 has_creds_for_current_protocol = (
298 values.get(protocol_key) if values else self.config.get_value(protocol_key)
299 )
300
301 if not has_creds_for_current_protocol:
302 # If pairing was started, show PIN entry
303 if self._active_pairing and self._active_pairing.is_pairing:
304 entries.append(
305 ConfigEntry(
306 key=CONF_PAIRING_PIN,
307 type=ConfigEntryType.STRING,
308 label="Enter the 4-digit PIN shown on the device",
309 required=True,
310 )
311 )
312 entries.append(
313 ConfigEntry(
314 key=CONF_ACTION_FINISH_PAIRING,
315 type=ConfigEntryType.ACTION,
316 label=f"Complete {protocol_name} pairing with the PIN",
317 action=CONF_ACTION_FINISH_PAIRING,
318 )
319 )
320 else:
321 # Show pairing instructions and start button
322 entries.append(
323 ConfigEntry(
324 key="pairing_instructions",
325 type=ConfigEntryType.LABEL,
326 label=(
327 f"This device requires {protocol_name} pairing before it can be used. "
328 "Click the button below to start the pairing process."
329 ),
330 )
331 )
332 entries.append(
333 ConfigEntry(
334 key=CONF_ACTION_START_PAIRING,
335 type=ConfigEntryType.ACTION,
336 label=f"Start {protocol_name} pairing",
337 action=CONF_ACTION_START_PAIRING,
338 )
339 )
340 else:
341 # Show paired status
342 entries.append(
343 ConfigEntry(
344 key="pairing_status",
345 type=ConfigEntryType.LABEL,
346 label=f"Device is paired ({protocol_name}) and ready to use.",
347 )
348 )
349 # Add reset pairing button
350 entries.append(
351 ConfigEntry(
352 key=CONF_ACTION_RESET_PAIRING,
353 type=ConfigEntryType.ACTION,
354 label=f"Reset {protocol_name} pairing",
355 action=CONF_ACTION_RESET_PAIRING,
356 )
357 )
358
359 # Store credentials (hidden from UI)
360 for protocol in (StreamingProtocol.RAOP, StreamingProtocol.AIRPLAY2):
361 conf_key = self._get_credentials_key(protocol)
362 entries.append(
363 ConfigEntry(
364 key=conf_key,
365 type=ConfigEntryType.SECURE_STRING,
366 label=conf_key,
367 default_value=None,
368 value=values.get(conf_key) if values else None,
369 required=False,
370 hidden=True,
371 )
372 )
373 return entries
374
375 async def _handle_pairing_action(
376 self, action: str, values: dict[str, ConfigValueType] | None
377 ) -> None:
378 """
379 Handle pairing actions.
380
381 Uses native pairing for both AirPlay 2 (HAP) and RAOP protocols.
382 Both produce credentials compatible with cliap2/cliraop respectively.
383 """
384 conf_protocol: int = 0
385 if values and (val := values.get(CONF_AIRPLAY_PROTOCOL)):
386 conf_protocol = cast("int", val)
387 else:
388 conf_protocol = cast("int", self.config.get_value(CONF_AIRPLAY_PROTOCOL, 0) or 0)
389 protocol = self._get_protocol_for_config_value(conf_protocol)
390 protocol_name = "RAOP" if protocol == StreamingProtocol.RAOP else "AirPlay"
391
392 if action == CONF_ACTION_START_PAIRING:
393 if self._active_pairing and self._active_pairing.is_pairing:
394 self.logger.warning("Pairing process already in progress for %s", self.display_name)
395 return
396
397 self.logger.info("Starting %s pairing for %s", protocol_name, self.display_name)
398
399 from .pairing import AirPlayPairing # noqa: PLC0415
400
401 # Determine port based on protocol
402 # Note: For Apple devices, pairing always happens on the AirPlay port (7000)
403 # even when streaming will use RAOP. The RAOP port (5000) is only for streaming.
404 port: int | None = None
405 if self.airplay_discovery_info:
406 port = self.airplay_discovery_info.port or 7000
407 elif self.raop_discovery_info:
408 # Fallback for devices without AirPlay service
409 port = self.raop_discovery_info.port or 5000
410 # Get the DACP ID from the provider - must match what cliap2 uses
411 provider = cast("AirPlayProvider", self.provider)
412 device_id = provider.dacp_id
413
414 self._active_pairing = AirPlayPairing(
415 address=self.address,
416 name=self.display_name,
417 protocol=protocol,
418 logger=self.logger,
419 port=port,
420 device_id=device_id,
421 )
422 await self._active_pairing.start_pairing()
423
424 elif action == CONF_ACTION_FINISH_PAIRING:
425 if not values:
426 return
427
428 pin = values.get(CONF_PAIRING_PIN)
429 if not pin:
430 self.logger.warning("No PIN provided for pairing")
431 return
432
433 if not self._active_pairing:
434 self.logger.warning("No active pairing session for %s", self.display_name)
435 return
436
437 credentials = await self._active_pairing.finish_pairing(pin=str(pin))
438 self._active_pairing = None
439
440 # Store credentials with the protocol-specific key
441 cred_key = self._get_credentials_key(protocol)
442 values[cred_key] = credentials
443
444 self.logger.info("Finished %s pairing for %s", protocol_name, self.display_name)
445
446 elif action == CONF_ACTION_RESET_PAIRING:
447 cred_key = self._get_credentials_key(protocol)
448 self.logger.info("Resetting %s pairing for %s", protocol_name, self.display_name)
449 if values is not None:
450 values[cred_key] = None
451
452 async def stop(self) -> None:
453 """Send STOP command to player."""
454 if self.stream and self.stream.session:
455 # forward stop to the entire stream session
456 await self.stream.session.stop()
457 self._attr_current_media = None
458 self.update_state()
459
460 async def play(self) -> None:
461 """Send PLAY (unpause) command to player."""
462 async with self._lock:
463 if self.stream and self.stream.running:
464 await self.stream.send_cli_command("ACTION=PLAY")
465
466 async def pause(self) -> None:
467 """Send PAUSE command to player."""
468 if self.group_members:
469 # pause is not supported while synced, use stop instead
470 self.logger.debug("Player is synced, using STOP instead of PAUSE")
471 await self.stop()
472 return
473
474 async with self._lock:
475 if not self.stream or not self.stream.running:
476 return
477 await self.stream.send_cli_command("ACTION=PAUSE")
478
479 async def play_media(self, media: PlayerMedia) -> None:
480 """Handle PLAY MEDIA on given player."""
481 if self.synced_to:
482 # this should not happen, but guard anyways
483 raise RuntimeError("Player is synced")
484 self._attr_current_media = media
485
486 # Always stop any existing stream
487 if self.stream and self.stream.running and self.stream.session:
488 # Set transitioning flag to ignore stale DACP messages (like prevent-playback)
489 self._transitioning = True
490 # Force stop the session (to speed up stopping)
491 await self.stream.session.stop(force=True)
492 self.stream = None
493
494 # select audio source
495 audio_source = self.mass.streams.get_stream(media, AIRPLAY_FLOW_PCM_FORMAT)
496
497 # setup StreamSession for player (and its sync childs if any)
498 sync_clients = self._get_sync_clients()
499 provider = cast("AirPlayProvider", self.provider)
500 stream_session = AirPlayStreamSession(provider, sync_clients, AIRPLAY_FLOW_PCM_FORMAT)
501 await stream_session.start(audio_source)
502 self._transitioning = False
503
504 async def volume_set(self, volume_level: int) -> None:
505 """Send VOLUME_SET command to given player."""
506 if self.stream and self.stream.running:
507 await self.stream.send_cli_command(f"VOLUME={volume_level}")
508 self._attr_volume_level = volume_level
509 self.update_state()
510 # store last state in cache
511 await self.mass.cache.set(
512 key=self.player_id,
513 data=volume_level,
514 provider=self.provider.instance_id,
515 category=CACHE_CATEGORY_PREV_VOLUME,
516 )
517
518 async def set_members(
519 self,
520 player_ids_to_add: list[str] | None = None,
521 player_ids_to_remove: list[str] | None = None,
522 ) -> None:
523 """Handle SET_MEMBERS command on the player."""
524 if self.synced_to:
525 # this should not happen, but guard anyways
526 raise RuntimeError("Player is synced, cannot set members")
527 if not player_ids_to_add and not player_ids_to_remove:
528 # nothing to do
529 return
530
531 stream_session = (
532 self.stream.session
533 if self.stream and self.stream.running and self.stream.session
534 else None
535 )
536 # handle removals first
537 if player_ids_to_remove:
538 if self.player_id in player_ids_to_remove:
539 # dissolve the entire sync group
540 if stream_session:
541 # stop the stream session if it is running
542 await stream_session.stop()
543 self._attr_group_members = []
544 self.update_state()
545 return
546
547 for child_player in self._get_sync_clients():
548 if child_player.player_id in player_ids_to_remove:
549 if stream_session:
550 await stream_session.remove_client(child_player)
551 if child_player.player_id in self._attr_group_members:
552 self._attr_group_members.remove(child_player.player_id)
553
554 # handle additions
555 for player_id in player_ids_to_add or []:
556 if player_id == self.player_id or player_id in self.group_members:
557 # nothing to do: player is already part of the group
558 continue
559 child_player_to_add: AirPlayPlayer | None = cast(
560 "AirPlayPlayer | None", self.mass.players.get(player_id)
561 )
562 if not child_player_to_add:
563 # should not happen, but guard against it
564 continue
565 if child_player_to_add.synced_to and child_player_to_add.synced_to != self.player_id:
566 raise RuntimeError("Player is already synced to another player")
567
568 # ensure the child does not have an existing stream session active
569 if child_player_to_add := cast(
570 "AirPlayPlayer | None", self.mass.players.get(player_id)
571 ):
572 if (
573 child_player_to_add.playback_state == PlaybackState.PAUSED
574 and child_player_to_add.stream
575 ):
576 # Stop the paused stream to avoid a deadlock situation
577 await child_player_to_add.stream.stop()
578 if (
579 child_player_to_add.stream
580 and child_player_to_add.stream.running
581 and child_player_to_add.stream.session
582 and child_player_to_add.stream.session != stream_session
583 ):
584 await child_player_to_add.stream.session.remove_client(child_player_to_add)
585
586 # add new child to the existing stream (RAOP or AirPlay2) session (if any)
587 self._attr_group_members.append(player_id)
588 if stream_session:
589 await stream_session.add_client(child_player_to_add)
590
591 # always update the state after modifying group members
592 self.update_state()
593
594 def _on_player_media_updated(self) -> None:
595 """Handle callback when the current media of the player is updated."""
596 if not self.stream or not self.stream.running or not self.stream.session:
597 return
598 metadata = self.current_media
599 if not metadata:
600 return
601 progress = int(metadata.corrected_elapsed_time or 0)
602 self.mass.create_task(self.stream.send_metadata(progress, metadata))
603
604 def update_volume_from_device(self, volume: int) -> None:
605 """Update volume from device feedback."""
606 ignore_volume_report = (
607 self.config.get_value(CONF_IGNORE_VOLUME)
608 or self.device_info.manufacturer.lower() == "apple"
609 )
610
611 if ignore_volume_report:
612 return
613
614 cur_volume = self.volume_level or 0
615 if abs(cur_volume - volume) > 3 or (time.time() - self.last_command_sent) > 3:
616 self.mass.create_task(self.volume_set(volume))
617 else:
618 self._attr_volume_level = volume
619 self.update_state()
620
621 def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None:
622 """Set/update the discovery info for the player."""
623 self._attr_name = display_name
624 if discovery_info.type == AIRPLAY_DISCOVERY_TYPE:
625 self.airplay_discovery_info = discovery_info
626 elif discovery_info.type == RAOP_DISCOVERY_TYPE:
627 self.raop_discovery_info = discovery_info
628 else: # guard
629 return
630 cur_address = self.address
631 new_address = get_primary_ip_address_from_zeroconf(discovery_info)
632 if new_address is None:
633 # should always be set, but guard against None
634 return
635 if cur_address != new_address:
636 self.logger.debug("Address updated from %s to %s", cur_address, new_address)
637 self.address = cur_address
638 self._attr_device_info.ip_address = new_address
639 self.update_state()
640
641 def set_state_from_stream(
642 self,
643 state: PlaybackState | None = None,
644 elapsed_time: float | None = None,
645 stream: AirPlayProtocol | None = None,
646 ) -> None:
647 """Set the playback state from stream (RAOP or AirPlay2).
648
649 :param state: New playback state (or None to keep current).
650 :param elapsed_time: New elapsed time (or None to keep current).
651 :param stream: The stream instance sending this update (for validation).
652 """
653 # Ignore state updates from old/stale streams
654 if stream is not None and stream != self.stream:
655 return
656
657 if state is not None:
658 prev_state = self._attr_playback_state
659 self._attr_playback_state = state
660 if self.stream and self.stream.session:
661 if prev_state == PlaybackState.PLAYING and state != PlaybackState.PLAYING:
662 self.stream.session.last_paused = time.time()
663 elif prev_state != PlaybackState.PLAYING and state == PlaybackState.PLAYING:
664 if self.stream.session.last_paused is not None:
665 pause_duration = time.time() - self.stream.session.last_paused
666 self.stream.session.total_pause_time += pause_duration
667 self.stream.session.last_paused = None
668 if elapsed_time is not None:
669 self._attr_elapsed_time = elapsed_time
670 self._attr_elapsed_time_last_updated = time.time()
671 self.update_state()
672
673 async def on_unload(self) -> None:
674 """Handle logic when the player is unloaded from the Player controller."""
675 await super().on_unload()
676 if self.stream:
677 # stop the stream session if it is running
678 if self.stream.running and self.stream.session:
679 self.mass.create_task(self.stream.session.stop())
680 self.stream = None
681 if self._active_pairing:
682 await self._active_pairing.close()
683 self._active_pairing = None
684
685 def _get_sync_clients(self) -> list[AirPlayPlayer]:
686 """Get all sync clients for a player."""
687 sync_clients: list[AirPlayPlayer] = []
688 # we need to return the player itself too
689 group_child_ids = {self.player_id}
690 group_child_ids.update(self.group_members)
691 for child_id in group_child_ids:
692 if client := cast("AirPlayPlayer | None", self.mass.players.get(child_id)):
693 sync_clients.append(client)
694 return sync_clients
695