/
/
/
1"""Sendspin Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from collections.abc import Callable
8from io import BytesIO
9from typing import TYPE_CHECKING, cast
10
11from aiosendspin.models import AudioCodec, MediaCommand
12from aiosendspin.models.types import PlaybackStateType
13from aiosendspin.models.types import RepeatMode as SendspinRepeatMode
14from aiosendspin.server import (
15 ClientEvent,
16 GroupEvent,
17 SendspinGroup,
18 VolumeChangedEvent,
19)
20from aiosendspin.server.audio import AudioFormat as SendspinAudioFormat
21from aiosendspin.server.client import DisconnectBehaviour
22from aiosendspin.server.events import (
23 ClientGroupChangedEvent,
24 GroupDeletedEvent,
25 GroupMemberAddedEvent,
26 GroupMemberRemovedEvent,
27 GroupStateChangedEvent,
28)
29from aiosendspin.server.roles import (
30 ArtworkGroupRole,
31 ControllerEvent,
32 ControllerGroupRole,
33 ControllerNextEvent,
34 ControllerPauseEvent,
35 ControllerPlayEvent,
36 ControllerPreviousEvent,
37 ControllerRepeatEvent,
38 ControllerShuffleEvent,
39 ControllerStopEvent,
40 MetadataGroupRole,
41)
42from aiosendspin.server.roles.metadata.state import Metadata
43from aiosendspin.server.roles.player.types import PlayerRoleProtocol
44from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
45from music_assistant_models.constants import PLAYER_CONTROL_NONE
46from music_assistant_models.enums import (
47 ConfigEntryType,
48 ImageType,
49 PlaybackState,
50 PlayerFeature,
51 PlayerType,
52 RepeatMode,
53)
54from music_assistant_models.player import DeviceInfo
55from PIL import Image
56
57from music_assistant.constants import (
58 CONF_ENTRY_HTTP_PROFILE_HIDDEN,
59 CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
60 CONF_ENTRY_SAMPLE_RATES,
61)
62from music_assistant.models.player import Player, PlayerMedia
63from music_assistant.providers.sendspin.playback import (
64 SendspinPlaybackSession,
65)
66
67# Supported group commands for Sendspin players
68SUPPORTED_GROUP_COMMANDS = [
69 MediaCommand.PLAY,
70 MediaCommand.PAUSE,
71 MediaCommand.STOP,
72 MediaCommand.NEXT,
73 MediaCommand.PREVIOUS,
74 MediaCommand.REPEAT_OFF,
75 MediaCommand.REPEAT_ONE,
76 MediaCommand.REPEAT_ALL,
77 MediaCommand.SHUFFLE,
78 MediaCommand.UNSHUFFLE,
79]
80
81# Config constants for Sendspin audio format
82CONF_PREFERRED_SENDSPIN_FORMAT = "preferred_sendspin_format"
83SENDSPIN_FORMAT_AUTOMATIC = "automatic"
84
85
86def format_to_option_value(fmt: SupportedAudioFormat) -> str:
87 """Convert SupportedAudioFormat to "codec:sample_rate:bit_depth:channels"."""
88 return f"{fmt.codec.value}:{fmt.sample_rate}:{fmt.bit_depth}:{fmt.channels}"
89
90
91def option_value_to_format(value: str) -> tuple[AudioCodec, SendspinAudioFormat] | None:
92 """Parse option value back to (AudioCodec, SendspinAudioFormat).
93
94 :param value: Option value in format "codec:sample_rate:bit_depth:channels".
95 :return: Tuple of (AudioCodec, SendspinAudioFormat) or None if parsing fails.
96 """
97 try:
98 codec_str, sample_rate_str, bit_depth_str, channels_str = value.split(":")
99 codec = AudioCodec(codec_str)
100 audio_format = SendspinAudioFormat(
101 sample_rate=int(sample_rate_str),
102 bit_depth=int(bit_depth_str),
103 channels=int(channels_str),
104 )
105 return (codec, audio_format)
106 except (ValueError, KeyError):
107 return None
108
109
110def format_to_display_string(fmt: SupportedAudioFormat) -> str:
111 """Convert to display string like "FLAC 48kHz/24bit stereo"."""
112 codec_name = fmt.codec.name
113 sample_rate_khz = fmt.sample_rate / 1000
114 # Format sample rate: show as integer if whole number, otherwise one decimal
115 if sample_rate_khz == int(sample_rate_khz):
116 sample_rate_str = f"{int(sample_rate_khz)}kHz"
117 else:
118 sample_rate_str = f"{sample_rate_khz:.1f}kHz"
119 if fmt.channels == 2:
120 channels_str = "stereo"
121 elif fmt.channels == 1:
122 channels_str = "mono"
123 else:
124 channels_str = f"{fmt.channels}ch"
125 return f"{codec_name} {sample_rate_str}/{fmt.bit_depth}bit {channels_str}"
126
127
128if TYPE_CHECKING:
129 from aiosendspin.models.player import SupportedAudioFormat
130 from aiosendspin.server.client import SendspinClient
131 from music_assistant_models.config_entries import ConfigValueType
132 from music_assistant_models.player_queue import PlayerQueue
133 from music_assistant_models.queue_item import QueueItem
134
135 from .provider import SendspinProvider
136
137
138class SendspinPlayer(Player):
139 """A sendspin audio player in Music Assistant."""
140
141 _attr_type = PlayerType.PROTOCOL
142
143 api: SendspinClient
144 unsub_event_cb: Callable[[], None]
145 unsub_group_event_cb: Callable[[], None]
146 last_sent_artwork_url: str | None = None
147 last_sent_artist_artwork_url: str | None = None
148 playback_session: SendspinPlaybackSession
149 is_web_player: bool = False
150
151 @property
152 def requires_flow_mode(self) -> bool:
153 """Return if the player requires flow mode."""
154 return True
155
156 def __init__(self, provider: SendspinProvider, player_id: str) -> None:
157 """Initialize the Player."""
158 super().__init__(provider, player_id)
159 sendspin_client = provider.server_api.get_client(player_id)
160 assert sendspin_client is not None
161 self.api = sendspin_client
162 self.api.disconnect_behaviour = DisconnectBehaviour.STOP
163 self.unsub_event_cb = sendspin_client.add_event_listener(self.event_cb)
164 self.unsub_group_event_cb = sendspin_client.group.add_event_listener(self.group_event_cb)
165 if controller_role := self._controller_role:
166 controller_role.set_supported_commands(SUPPORTED_GROUP_COMMANDS)
167
168 self.playback_session = SendspinPlaybackSession(self)
169
170 self.logger = self.provider.logger.getChild(player_id)
171 # init some static variables
172 self._attr_name = sendspin_client.name
173 self._attr_supported_features = {
174 PlayerFeature.PLAY_MEDIA,
175 PlayerFeature.SET_MEMBERS,
176 PlayerFeature.VOLUME_SET,
177 PlayerFeature.VOLUME_MUTE,
178 PlayerFeature.MULTI_DEVICE_DSP,
179 }
180 self._attr_can_group_with = {provider.instance_id}
181 self._attr_power_control = PLAYER_CONTROL_NONE
182 if device_info := sendspin_client.info.device_info:
183 self._attr_device_info = DeviceInfo(
184 model=device_info.product_name or "Unknown model",
185 manufacturer=device_info.manufacturer or "Unknown Manufacturer",
186 software_version=device_info.software_version,
187 )
188 else:
189 self._attr_device_info = DeviceInfo()
190 if sendspin_client.info.player_support:
191 for role in sendspin_client.roles_by_family("player"):
192 volume = role.get_player_volume()
193 muted = role.get_player_muted()
194 if volume is not None:
195 self._attr_volume_level = volume
196 if muted is not None:
197 self._attr_volume_muted = muted
198 if volume is not None or muted is not None:
199 break
200 self._attr_available = True
201 self.is_web_player = sendspin_client.name.startswith(
202 "Web (" # The regular Web Interface
203 ) or sendspin_client.name.startswith(
204 "PWA (" # The PWA App
205 )
206 self._attr_expose_to_ha_by_default = not self.is_web_player
207 self._attr_hidden_by_default = self.is_web_player
208
209 @property
210 def _artwork_role(self) -> ArtworkGroupRole | None:
211 """Get the ArtworkGroupRole for this player's group."""
212 role = self.api.group.group_role("artwork")
213 if isinstance(role, ArtworkGroupRole):
214 return role
215 return None
216
217 @property
218 def _metadata_role(self) -> MetadataGroupRole | None:
219 """Get the MetadataGroupRole for this player's group."""
220 role = self.api.group.group_role("metadata")
221 if isinstance(role, MetadataGroupRole):
222 return role
223 return None
224
225 @property
226 def _controller_role(self) -> ControllerGroupRole | None:
227 """Get the ControllerGroupRole for this player's group."""
228 role = self.api.group.group_role("controller")
229 if isinstance(role, ControllerGroupRole):
230 return role
231 return None
232
233 @property
234 def _player_role(self) -> PlayerRoleProtocol | None:
235 """Get the player role for this client (not group role)."""
236 for role in self.api.roles_by_family("player"):
237 if isinstance(role, PlayerRoleProtocol):
238 return role
239 return None
240
241 async def _handle_controller_event(self, event: ControllerEvent) -> None:
242 """Handle a controller event from the ControllerGroupRole."""
243 queue = self.mass.player_queues.get_active_queue(self.player_id)
244 match event:
245 case ControllerPlayEvent():
246 await self.mass.players.cmd_play(self.player_id)
247 case ControllerPauseEvent():
248 await self.mass.players.cmd_pause(self.player_id)
249 case ControllerStopEvent():
250 await self.mass.players.cmd_stop(self.player_id)
251 case ControllerNextEvent():
252 await self.mass.players.cmd_next_track(self.player_id)
253 case ControllerPreviousEvent():
254 await self.mass.players.cmd_previous_track(self.player_id)
255 case ControllerRepeatEvent(mode=mode) if queue:
256 match mode:
257 case SendspinRepeatMode.OFF:
258 self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.OFF)
259 case SendspinRepeatMode.ONE:
260 self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ONE)
261 case SendspinRepeatMode.ALL:
262 self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL)
263 case ControllerShuffleEvent(shuffle=shuffle) if queue:
264 await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=shuffle)
265
266 async def _sync_membership_from_group(self, group: SendspinGroup) -> None:
267 """Sync MA/player + playback session membership from authoritative group state."""
268 # Ignore stale events from a group we no longer belong to.
269 if group is not self.api.group:
270 return
271 group_client_ids = [client.client_id for client in group.clients]
272 is_leader = bool(group_client_ids) and group_client_ids[0] == self.player_id
273 desired_group_members = group_client_ids if is_leader else []
274 desired_session_members = group_client_ids[1:] if is_leader else []
275 if self._attr_group_members != desired_group_members:
276 self._attr_group_members = desired_group_members
277 self.update_state()
278 # Only use STOP when we actually lead other members.
279 self.api.disconnect_behaviour = (
280 DisconnectBehaviour.STOP
281 if is_leader and len(desired_session_members) > 0
282 else DisconnectBehaviour.UNGROUP
283 )
284 await self.playback_session.sync_members(set(desired_session_members))
285
286 def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
287 """Event callback registered to the sendspin client."""
288 match event:
289 case VolumeChangedEvent(volume=volume, muted=muted):
290 self._attr_volume_level = volume
291 self._attr_volume_muted = muted
292 self.update_state()
293 case ClientGroupChangedEvent(new_group=new_group):
294 self.unsub_group_event_cb()
295 self.unsub_group_event_cb = new_group.add_event_listener(self.group_event_cb)
296 if controller_role := self._controller_role:
297 controller_role.set_supported_commands(SUPPORTED_GROUP_COMMANDS)
298 # Cancel active playback - push stream belongs to the old group
299 self.mass.create_task(self.playback_session.cancel("group changed"))
300 # Sync playback state from the new group
301 match new_group.state:
302 case PlaybackStateType.PLAYING:
303 self._attr_playback_state = PlaybackState.PLAYING
304 case PlaybackStateType.PAUSED:
305 self._attr_playback_state = PlaybackState.PAUSED
306 case PlaybackStateType.STOPPED:
307 self._attr_playback_state = PlaybackState.IDLE
308 self._attr_elapsed_time = 0
309 self._attr_elapsed_time_last_updated = time.time()
310 # Update in case this is a newly created group
311 # GroupMemberAddedEvent or GroupMemberRemovedEvent will be fired before this
312 # so group members are already up to date at this point
313 self.mass.create_task(self._sync_membership_from_group(new_group))
314 self.update_state()
315
316 def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
317 """Event callback registered to the sendspin group this player belongs to."""
318 if self.synced_to is not None:
319 # Only handle group events as the leader, except for:
320 # - GroupMemberRemovedEvent: to handle being removed from a group
321 # - GroupStateChangedEvent: to update playback state when leader stops/disconnects
322 if not isinstance(event, (GroupMemberRemovedEvent, GroupStateChangedEvent)):
323 return
324 match event:
325 case GroupStateChangedEvent(state=state):
326 match state:
327 case PlaybackStateType.PLAYING:
328 self._attr_playback_state = PlaybackState.PLAYING
329 case PlaybackStateType.PAUSED:
330 self._attr_playback_state = PlaybackState.PAUSED
331 case PlaybackStateType.STOPPED:
332 self._attr_playback_state = PlaybackState.IDLE
333 self._attr_elapsed_time = 0
334 self._attr_elapsed_time_last_updated = time.time()
335 if self.synced_to is None:
336 self.mass.create_task(self.playback_session.cancel("group stopped"))
337 self.update_state()
338 case GroupMemberAddedEvent(client_id=client_id):
339 is_group_leader = (
340 bool(group.clients) and group.clients[0].client_id == self.player_id
341 )
342 if is_group_leader and (
343 not self._attr_group_members or self._attr_group_members[0] != self.player_id
344 ):
345 self._attr_group_members = [self.player_id, *self._attr_group_members]
346 if client_id not in self._attr_group_members:
347 self._attr_group_members.append(client_id)
348 self.update_state()
349 self.mass.create_task(self.playback_session.add_member(client_id))
350 self.mass.create_task(self._sync_membership_from_group(group))
351 case GroupMemberRemovedEvent(client_id=client_id):
352 self.mass.create_task(self.playback_session.remove_member(client_id))
353 self.mass.create_task(self._handle_group_member_removed(group, client_id))
354 self.mass.create_task(self._sync_membership_from_group(group))
355 case GroupDeletedEvent():
356 pass
357 case ControllerEvent() as controller_event:
358 if self.synced_to is None:
359 self.mass.create_task(self._handle_controller_event(controller_event))
360
361 async def _handle_group_member_removed(self, group: SendspinGroup, client_id: str) -> None:
362 """Handle a group member being removed asynchronously."""
363 if client_id == self.player_id:
364 if len(group.clients) > 0:
365 # We were just removed as a leader:
366 # 1. stop playback on the old group
367 await group.stop()
368 # 2. clear our members (since we are now alone in a new group)
369 self._attr_group_members = []
370 self.update_state()
371 elif client_id in self._attr_group_members:
372 # Someone else left our group
373 self._attr_group_members.remove(client_id)
374 self.update_state()
375
376 async def volume_set(self, volume_level: int) -> None:
377 """Handle VOLUME_SET command on the player."""
378 roles = self.api.roles_by_family("player")
379 for role in roles:
380 role.set_player_volume(volume_level)
381
382 async def volume_mute(self, muted: bool) -> None:
383 """Handle VOLUME MUTE command on the player."""
384 roles = self.api.roles_by_family("player")
385 for role in roles:
386 role.set_player_mute(muted)
387
388 async def stop(self) -> None:
389 """Stop command."""
390 self.logger.debug("Received STOP command on player %s", self.display_name)
391 self.mark_stop_called()
392 self._attr_current_media = None
393 self._attr_playback_state = PlaybackState.IDLE
394 self._attr_elapsed_time = 0
395 self._attr_elapsed_time_last_updated = time.time()
396 self.update_state()
397 await self.playback_session.cancel("stop command")
398 await self.api.group.stop()
399
400 async def play_media(self, media: PlayerMedia) -> None:
401 """Play media command."""
402 self.logger.debug(
403 "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
404 )
405
406 # Update player state optimistically
407 self._attr_current_media = media
408 self._attr_elapsed_time = 0
409 self._attr_elapsed_time_last_updated = time.time()
410 # playback_state will be set by the group state change event
411
412 # Stop previous stream in case we were already playing something
413 await self.playback_session.cancel("new media requested")
414 await self.api.group.stop()
415 await self.playback_session.start(media)
416 self.update_state()
417
418 async def on_config_updated(self) -> None:
419 """Apply preferred format when config changes."""
420 await self._apply_preferred_format()
421
422 async def _apply_preferred_format(self) -> None:
423 """Read config and call set_preferred_format() if not automatic."""
424 player_role = self._player_role
425 if player_role is None:
426 return
427
428 config_value = cast(
429 "str",
430 self.config.get_value(CONF_PREFERRED_SENDSPIN_FORMAT, SENDSPIN_FORMAT_AUTOMATIC),
431 )
432 if config_value == SENDSPIN_FORMAT_AUTOMATIC:
433 # Automatic mode: don't set a preferred format, let client decide.
434 return
435
436 parsed = option_value_to_format(config_value)
437 if parsed is None:
438 self.logger.warning(
439 "Invalid audio format config value '%s' for player %s",
440 config_value,
441 self.display_name,
442 )
443 return
444
445 codec, audio_format = parsed
446 if not player_role.set_preferred_format(audio_format, codec):
447 self.logger.warning(
448 "Failed to set preferred audio format %s %s for player %s",
449 codec.name,
450 audio_format,
451 self.display_name,
452 )
453
454 async def set_members(
455 self,
456 player_ids_to_add: list[str] | None = None,
457 player_ids_to_remove: list[str] | None = None,
458 ) -> None:
459 """Handle SET_MEMBERS command on the player."""
460 for player_id in player_ids_to_remove or []:
461 player = self.mass.players.get_player(player_id, True)
462 player = cast("SendspinPlayer", player) # For type checking
463 await self.api.group.remove_client(player.api)
464 for player_id in player_ids_to_add or []:
465 player = self.mass.players.get_player(player_id, True)
466 player = cast("SendspinPlayer", player) # For type checking
467 await self.api.group.add_client(player.api)
468 # self.group_members will be updated by the group event callback
469
470 async def _send_album_artwork(self, current_item: QueueItem) -> str | None:
471 """
472 Send album artwork to the sendspin group.
473
474 Args:
475 current_item: The current queue item.
476 """
477 artwork_url = None
478 if current_item.image is not None:
479 artwork_url = self.mass.metadata.get_image_url(current_item.image)
480
481 if artwork_url != self.last_sent_artwork_url:
482 # Image changed, resend the artwork
483 self.last_sent_artwork_url = artwork_url
484 if artwork_url is not None and current_item.media_item is not None:
485 image_data = await self.mass.metadata.get_image_data_for_item(
486 current_item.media_item
487 )
488 if image_data is not None:
489 image = await asyncio.to_thread(Image.open, BytesIO(image_data))
490 if (artwork_role := self._artwork_role) is not None:
491 await artwork_role.set_album_artwork(image)
492 # Clear artwork if none available
493 elif (artwork_role := self._artwork_role) is not None:
494 await artwork_role.set_album_artwork(None)
495
496 return artwork_url
497
498 async def _send_artist_artwork(self, current_item: QueueItem) -> None:
499 """
500 Send artist artwork to the sendspin group.
501
502 Args:
503 current_item: The current queue item.
504 """
505 # Extract primary artist if available
506 artist_artwork_url = None
507 if current_item.media_item is not None and hasattr(current_item.media_item, "artists"):
508 artists = getattr(current_item.media_item, "artists", None)
509 if artists and len(artists) > 0:
510 primary_artist = artists[0]
511 if hasattr(primary_artist, "image"):
512 artist_image = getattr(primary_artist, "image", None)
513 if artist_image is not None:
514 artist_artwork_url = self.mass.metadata.get_image_url(artist_image)
515
516 if artist_artwork_url != self.last_sent_artist_artwork_url:
517 # Artist image changed, resend the artwork
518 self.last_sent_artist_artwork_url = artist_artwork_url
519 if artist_artwork_url is not None:
520 artist_image_data = await self.mass.metadata.get_image_data_for_item(
521 primary_artist, img_type=ImageType.THUMB
522 )
523 if artist_image_data is not None:
524 artist_image = await asyncio.to_thread(Image.open, BytesIO(artist_image_data))
525 if (artwork_role := self._artwork_role) is not None:
526 await artwork_role.set_artist_artwork(artist_image)
527 # Clear artist artwork if none available
528 elif (artwork_role := self._artwork_role) is not None:
529 await artwork_role.set_artist_artwork(None)
530
531 def _on_player_media_updated(self) -> None:
532 """Handle callback when the current media of the player is updated."""
533 if self.synced_to is not None:
534 # Only leader sends metadata
535 return
536
537 if self.state.current_media is None:
538 # Clear metadata when no media loaded
539 if (metadata_role := self._metadata_role) is not None:
540 metadata_role.set_metadata(Metadata())
541 return
542 self.mass.create_task(self.send_current_media_metadata())
543
544 async def send_current_media_metadata(self) -> None:
545 """Send the current media metadata to the sendspin group."""
546 if not self.available:
547 return
548 current_media = self.state.current_media
549 if current_media is None:
550 return
551 # check if we are playing a MA queue item
552 queue_item: QueueItem | None = None
553 queue: PlayerQueue | None = None
554 if current_media.source_id and current_media.queue_item_id:
555 queue = self.mass.player_queues.get(current_media.source_id)
556 queue_item = self.mass.player_queues.get_item(
557 current_media.source_id, current_media.queue_item_id
558 )
559
560 # Send album and artist artwork
561 if queue_item:
562 await self._send_album_artwork(queue_item)
563 await self._send_artist_artwork(queue_item)
564
565 track_duration = current_media.duration or 0
566 repeat = SendspinRepeatMode.OFF
567 if queue and queue.repeat_mode == RepeatMode.ALL:
568 repeat = SendspinRepeatMode.ALL
569 elif queue and queue.repeat_mode == RepeatMode.ONE:
570 repeat = SendspinRepeatMode.ONE
571
572 shuffle = queue.shuffle_enabled if queue else False
573
574 metadata = Metadata(
575 title=current_media.title,
576 artist=current_media.artist,
577 album_artist=None,
578 album=current_media.album,
579 artwork_url=current_media.image_url,
580 year=None,
581 track=None,
582 track_duration=track_duration * 1000 if track_duration is not None else None,
583 track_progress=int(current_media.corrected_elapsed_time * 1000)
584 if current_media.corrected_elapsed_time
585 else 0,
586 playback_speed=1000,
587 repeat=repeat,
588 shuffle=shuffle,
589 )
590
591 # Send metadata to the group
592 if (metadata_role := self._metadata_role) is not None:
593 metadata_role.set_metadata(metadata)
594
595 async def get_config_entries(
596 self,
597 action: str | None = None,
598 values: dict[str, ConfigValueType] | None = None,
599 ) -> list[ConfigEntry]:
600 """Return all (provider/player specific) Config Entries for the player."""
601 default_entries = await super().get_config_entries(action=action, values=values)
602 entries = [
603 *default_entries,
604 CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
605 CONF_ENTRY_HTTP_PROFILE_HIDDEN,
606 ConfigEntry.from_dict({**CONF_ENTRY_SAMPLE_RATES.to_dict(), "hidden": True}),
607 ]
608
609 # Build dynamic format options from player's supported formats
610 player_role = self._player_role
611 if player_role is not None:
612 supported_formats = player_role.get_supported_formats()
613 if supported_formats:
614 format_options = [
615 ConfigValueOption(
616 title="Automatic (let client decide)",
617 value=SENDSPIN_FORMAT_AUTOMATIC,
618 ),
619 ]
620 for fmt in supported_formats:
621 format_options.append(
622 ConfigValueOption(
623 title=format_to_display_string(fmt),
624 value=format_to_option_value(fmt),
625 )
626 )
627 entries.append(
628 ConfigEntry(
629 key=CONF_PREFERRED_SENDSPIN_FORMAT,
630 type=ConfigEntryType.STRING,
631 label="Preferred audio format",
632 description="Select the audio format to use for playback on this player.",
633 category="protocol_generic",
634 default_value=SENDSPIN_FORMAT_AUTOMATIC,
635 options=format_options,
636 advanced=True,
637 )
638 )
639
640 return entries
641
642 async def on_unload(self) -> None:
643 """Handle logic when the player is unloaded from the Player controller."""
644 await self.playback_session.close()
645 await super().on_unload()
646 self.unsub_event_cb()
647 self.unsub_group_event_cb()
648