/
/
/
1"""Squeezelite Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import statistics
7import struct
8import time
9from collections import deque
10from collections.abc import Iterator
11from typing import TYPE_CHECKING, cast
12
13from aioslimproto.models import EventType as SlimEventType
14from aioslimproto.models import PlayerState as SlimPlayerState
15from aioslimproto.models import Preset as SlimPreset
16from aioslimproto.models import SlimEvent
17from aioslimproto.models import VisualisationType as SlimVisualisationType
18from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
19from music_assistant_models.enums import (
20 ConfigEntryType,
21 IdentifierType,
22 MediaType,
23 PlaybackState,
24 PlayerFeature,
25 PlayerType,
26 RepeatMode,
27)
28from music_assistant_models.errors import InvalidCommand, MusicAssistantError
29from music_assistant_models.media_items import AudioFormat
30
31from music_assistant.constants import (
32 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
33 CONF_ENTRY_SUPPORT_GAPLESS_DIFFERENT_SAMPLE_RATES,
34 CONF_ENTRY_SYNC_ADJUST,
35 INTERNAL_PCM_FORMAT,
36 VERBOSE_LOG_LEVEL,
37 create_sample_rates_config_entry,
38)
39from music_assistant.helpers.util import TaskManager
40from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
41
42from .constants import (
43 CONF_ENTRY_DISPLAY,
44 CONF_ENTRY_VISUALIZATION,
45 DEFAULT_PLAYER_VOLUME,
46 DEVIATION_JUMP_IGNORE,
47 MAX_SKIP_AHEAD_MS,
48 MIN_DEVIATION_ADJUST,
49 MIN_REQ_PLAYPOINTS,
50 REPEATMODE_MAP,
51 STATE_MAP,
52 SyncPlayPoint,
53)
54from .multi_client_stream import MultiClientStream
55
56if TYPE_CHECKING:
57 from aioslimproto.client import SlimClient
58
59 from .provider import SqueezelitePlayerProvider
60
61
62CACHE_CATEGORY_PREV_STATE = 0 # category for caching previous player state
63
64PLAYER_DEVICE_TYPES = {
65 # list of device types that are considered real hardware players
66 "squeezebox",
67 "squeezebox2",
68 "transporter",
69 "receiver",
70 "controller",
71 "boom",
72}
73
74
75class SqueezelitePlayer(Player):
76 """Squeezelite Player implementation."""
77
78 def __init__(
79 self,
80 provider: SqueezelitePlayerProvider,
81 player_id: str,
82 client: SlimClient,
83 ) -> None:
84 """Initialize the Squeezelite Player."""
85 super().__init__(provider, player_id)
86 self.client = client
87 self._provider: SqueezelitePlayerProvider = provider
88 # Set static player attributes
89 self._attr_supported_features = {
90 PlayerFeature.PLAY_MEDIA,
91 PlayerFeature.POWER,
92 PlayerFeature.SET_MEMBERS,
93 PlayerFeature.MULTI_DEVICE_DSP,
94 PlayerFeature.VOLUME_SET,
95 PlayerFeature.PAUSE,
96 PlayerFeature.ENQUEUE,
97 PlayerFeature.GAPLESS_PLAYBACK,
98 }
99 self._attr_can_group_with = {provider.instance_id}
100 self.multi_client_stream: MultiClientStream | None = None
101 self._sync_playpoints: deque[SyncPlayPoint] = deque(maxlen=MIN_REQ_PLAYPOINTS)
102 self._do_not_resync_before: float = 0.0
103 self._plugin_source_active: bool = False
104 # TEMP: patch slimclient send_strm to adjust buffer thresholds
105 # this can be removed when we did a new release of aioslimproto with this change
106 # after this has been tested in beta for a while
107 client._send_strm = lambda *args, **kwargs: _patched_send_strm(
108 client, self, *args, **kwargs
109 )
110
111 async def on_config_updated(self) -> None:
112 """Handle logic when the player is registered or the config was updated."""
113 # set presets and display
114 await self._set_preset_items()
115 await self._set_display()
116
117 async def setup(self) -> None:
118 """Set up the player."""
119 player_id = self.client.player_id
120 self.logger.info("Player %s connected", self.client.name or player_id)
121 # update all dynamic attributes
122 self.update_attributes()
123 # restore volume and power state
124 if last_state := await self.mass.cache.get(
125 key=player_id, provider=self.provider.instance_id, category=CACHE_CATEGORY_PREV_STATE
126 ):
127 init_power = last_state[0]
128 init_volume = last_state[1]
129 else:
130 init_volume = DEFAULT_PLAYER_VOLUME
131 init_power = False
132 await self.client.power(init_power)
133 await self.client.stop()
134 await self.client.volume_set(init_volume)
135 await self.mass.players.register_or_update(self)
136
137 async def get_config_entries(
138 self,
139 action: str | None = None,
140 values: dict[str, ConfigValueType] | None = None,
141 ) -> list[ConfigEntry]:
142 """Return all (provider/player specific) Config Entries for the player."""
143 base_entries = await super().get_config_entries(action=action, values=values)
144 max_sample_rate = int(self.client.max_sample_rate)
145 # create preset entries (for players that support it)
146 presets = []
147 async for playlist in self.mass.music.playlists.iter_library_items(True):
148 presets.append(ConfigValueOption(playlist.name, playlist.uri))
149 async for radio in self.mass.music.radio.iter_library_items(True):
150 presets.append(ConfigValueOption(radio.name, radio.uri))
151 preset_count = 10
152 preset_entries = [
153 ConfigEntry(
154 key=f"preset_{index}",
155 type=ConfigEntryType.STRING,
156 options=presets,
157 label=f"Preset {index}",
158 description="Assign a playable item to the player's preset. "
159 "Only supported on real squeezebox hardware or jive(lite) based emulators.",
160 category="presets",
161 required=False,
162 )
163 for index in range(1, preset_count + 1)
164 ]
165 return [
166 *base_entries,
167 *preset_entries,
168 CONF_ENTRY_SYNC_ADJUST,
169 CONF_ENTRY_DISPLAY,
170 CONF_ENTRY_VISUALIZATION,
171 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
172 create_sample_rates_config_entry(
173 max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24
174 ),
175 CONF_ENTRY_SUPPORT_GAPLESS_DIFFERENT_SAMPLE_RATES,
176 ]
177
178 async def power(self, powered: bool) -> None:
179 """Handle POWER command on the player."""
180 await self.client.power(powered)
181 # store last state in cache
182 await self.mass.cache.set(
183 key=self.player_id,
184 data=(powered, self.client.volume_level),
185 provider=self.provider.instance_id,
186 category=CACHE_CATEGORY_PREV_STATE,
187 )
188
189 async def volume_set(self, volume_level: int) -> None:
190 """Handle VOLUME_SET command on the player."""
191 await self.client.volume_set(volume_level)
192 # store last state in cache
193 await self.mass.cache.set(
194 key=self.player_id,
195 data=(self.client.powered, volume_level),
196 provider=self.provider.instance_id,
197 category=CACHE_CATEGORY_PREV_STATE,
198 )
199
200 async def volume_mute(self, muted: bool) -> None:
201 """Handle VOLUME MUTE command on the player."""
202 await self.client.mute(muted)
203
204 async def stop(self) -> None:
205 """Handle STOP command on the player."""
206 self._plugin_source_active = False
207 # Clean up any existing multi-client stream
208 if self.multi_client_stream is not None:
209 await self.multi_client_stream.stop()
210 self.multi_client_stream = None
211 async with TaskManager(self.mass) as tg:
212 for client in self._get_sync_clients():
213 tg.create_task(client.stop())
214 self.update_state()
215
216 async def play(self) -> None:
217 """Handle PLAY command on the player."""
218 async with TaskManager(self.mass) as tg:
219 for client in self._get_sync_clients():
220 tg.create_task(client.play())
221
222 async def pause(self) -> None:
223 """Handle PAUSE command on the player."""
224 async with TaskManager(self.mass) as tg:
225 for client in self._get_sync_clients():
226 tg.create_task(client.pause())
227
228 async def play_media(self, media: PlayerMedia) -> None:
229 """Handle PLAY MEDIA on the player."""
230 if self.synced_to:
231 msg = "A synced player cannot receive play commands directly"
232 raise InvalidCommand(msg)
233
234 # Clean up any existing multi-client stream before starting a new one
235 if self.multi_client_stream is not None:
236 await self.multi_client_stream.stop()
237 self.multi_client_stream = None
238
239 # Clear next media item during announcements to prevent playing the
240 # next enqueued track after it finishes.
241 if media.media_type == MediaType.ANNOUNCEMENT:
242 self.client._next_media = None
243
244 if not self.group_members:
245 # Simple, single-player playback
246 stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
247 await self._handle_play_url_for_slimplayer(
248 self.client,
249 url=stream_url,
250 media=media,
251 send_flush=True,
252 auto_play=False,
253 )
254 return
255
256 # this is a syncgroup, we need to handle this with a multi client stream
257 # Use a fixed 96kHz/24-bit format for syncgroup playback
258 master_audio_format = AudioFormat(
259 content_type=INTERNAL_PCM_FORMAT.content_type,
260 sample_rate=96000,
261 bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
262 channels=2,
263 )
264
265 # select audio source, we force flow mode
266 # because multi-client streaming does not support enqueueing
267 audio_source = self.mass.streams.get_stream(
268 media, master_audio_format, force_flow_mode=True
269 )
270
271 # start the stream task
272 self.multi_client_stream = stream = MultiClientStream(
273 audio_source=audio_source, audio_format=master_audio_format
274 )
275 base_url = (
276 f"{self.mass.streams.base_url}/slimproto/multi?player_id={self.player_id}&fmt=flac"
277 )
278
279 # Count how many clients will connect
280 expected_clients = len(list(self._get_sync_clients()))
281 stream.expected_clients = expected_clients
282
283 # forward to downstream play_media commands
284 async with TaskManager(self.mass) as tg:
285 for slimplayer in self._get_sync_clients():
286 url = f"{base_url}&child_player_id={slimplayer.player_id}"
287 tg.create_task(
288 self._handle_play_url_for_slimplayer(
289 slimplayer,
290 url=url,
291 media=media,
292 send_flush=True,
293 auto_play=False,
294 is_group_playback=True,
295 )
296 )
297
298 async def enqueue_next_media(self, media: PlayerMedia) -> None:
299 """Handle enqueuing next media item."""
300 await self._handle_play_url_for_slimplayer(
301 self.client,
302 url=media.uri,
303 media=media,
304 enqueue=True,
305 send_flush=False,
306 auto_play=True,
307 )
308
309 async def set_members(
310 self,
311 player_ids_to_add: list[str] | None = None,
312 player_ids_to_remove: list[str] | None = None,
313 ) -> None:
314 """Handle SET_MEMBERS command on the player."""
315 if self.synced_to:
316 # this should not happen, but guard anyways
317 raise InvalidCommand("Player is synced, cannot set members")
318 if not player_ids_to_add and not player_ids_to_remove:
319 # nothing to do
320 return
321
322 # handle removals first
323 if player_ids_to_remove:
324 for sync_client in self._get_sync_clients():
325 if sync_client.player_id in player_ids_to_remove:
326 if sync_client.player_id in self._attr_group_members:
327 # remove child from the group
328 self._attr_group_members.remove(sync_client.player_id)
329 if sync_client.state != SlimPlayerState.STOPPED:
330 # stop the player if it is playing
331 await sync_client.stop()
332
333 # handle additions
334 players_added = False
335 for player_id in player_ids_to_add or []:
336 if player_id == self.player_id or player_id in self.group_members:
337 # nothing to do: player is already part of the group
338 continue
339 child_player = cast("SqueezelitePlayer | None", self.mass.players.get_player(player_id))
340 if not child_player:
341 # should not happen, but guard against it
342 continue
343 if child_player.state != SlimPlayerState.STOPPED:
344 # stop the player if it is already playing something else
345 await child_player.stop()
346 self._attr_group_members.append(player_id)
347 players_added = True
348
349 # always update the state after modifying group members
350 self.update_state()
351
352 if (
353 (players_added or player_ids_to_remove)
354 and self.state.current_media
355 and self._attr_playback_state == PlaybackState.PLAYING
356 ):
357 # restart stream session if it was already playing
358 # for now, we dont support late joining into an existing stream
359 self.mass.create_task(self.mass.players.cmd_resume(self.player_id))
360
361 def handle_slim_event(self, event: SlimEvent) -> None:
362 """Handle player event from slimproto server."""
363 if event.type == SlimEventType.PLAYER_BUFFER_READY:
364 self.mass.create_task(self._handle_buffer_ready())
365 return
366
367 if event.type == SlimEventType.PLAYER_HEARTBEAT:
368 self._handle_player_heartbeat()
369 return
370
371 if event.type in (SlimEventType.PLAYER_BTN_EVENT, SlimEventType.PLAYER_CLI_EVENT):
372 self.mass.create_task(self._handle_player_cli_event(event))
373 return
374
375 # all other: update attributes and update state
376 self.update_attributes()
377 self.update_state()
378
379 def update_attributes(self) -> None:
380 """Update player attributes from slim player."""
381 # Update player state from slim player
382 self._attr_type = (
383 PlayerType.PLAYER
384 if self.client.device_type in PLAYER_DEVICE_TYPES
385 else PlayerType.PROTOCOL
386 )
387 self._attr_available = self.client.connected
388 self._attr_name = self.client.name
389 self._attr_powered = self.client.powered
390 old_state = self._attr_playback_state
391 self._attr_playback_state = STATE_MAP[self.client.state]
392 self._attr_volume_level = self.client.volume_level
393 self._attr_volume_muted = self.client.muted
394 self._attr_device_info = DeviceInfo(
395 model=self.client.device_model,
396 manufacturer=self.client.device_type,
397 )
398 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.client.device_address)
399 # player_id is the MAC address in slimproto
400 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, self.client.player_id)
401 if (
402 old_state != PlaybackState.PLAYING
403 and self._attr_playback_state == PlaybackState.PLAYING
404 ):
405 # Invalidate elapsed time interpolation to avoid jumps when resuming from pause/stop
406 # We need this because some players (e.g. WiiM) keep sending increasing elapsed time
407 self._attr_elapsed_time_last_updated = time.time()
408 # Update current media if available
409 if self.client.current_media and (metadata := self.client.current_media.metadata):
410 self._attr_current_media = PlayerMedia(
411 uri=metadata.get("item_id"),
412 title=metadata.get("title"),
413 album=metadata.get("album"),
414 artist=metadata.get("artist"),
415 image_url=metadata.get("image_url"),
416 duration=metadata.get("duration"),
417 source_id=metadata.get("source_id"),
418 queue_item_id=metadata.get("queue_item_id"),
419 )
420 # Set active source from metadata if available, otherwise use player_id
421 self._attr_active_source = metadata.get("source_id") or self.player_id
422 else:
423 self._attr_current_media = None
424 self._attr_active_source = self.player_id
425
426 async def _handle_play_url_for_slimplayer(
427 self,
428 slimplayer: SlimClient,
429 url: str,
430 media: PlayerMedia,
431 enqueue: bool = False,
432 send_flush: bool = True,
433 auto_play: bool = False,
434 is_group_playback: bool = False,
435 ) -> None:
436 """Handle playback of an url on slimproto player(s)."""
437 metadata = {
438 "item_id": media.uri,
439 "title": media.title,
440 "album": media.album,
441 "artist": media.artist,
442 "image_url": media.image_url,
443 "duration": media.duration,
444 "source_id": media.source_id,
445 "queue_item_id": media.queue_item_id,
446 }
447 queue = None
448 if media.source_id and (queue := self.mass.player_queues.get(media.source_id)):
449 self.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
450 self.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
451 source_id = media.source_id or (media.custom_data or {}).get("source_id")
452 self._plugin_source_active = (
453 source_id is not None and self.mass.players.get_plugin_source(source_id) is not None
454 )
455 await slimplayer.play_url(
456 url=url,
457 mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
458 metadata=metadata,
459 enqueue=enqueue,
460 send_flush=send_flush,
461 # if autoplay=False playback will not start automatically
462 # instead 'buffer ready' will be called when the buffer is full
463 # to coordinate a start of multiple synced players
464 autostart=auto_play,
465 )
466 # TODO: When we implement server clock sync, we can remove the pause here
467 # and rely on unpause_at + HEADROOM in the buffer_ready handler. LMS
468 # also does NOT use an explicit pause. For now, we pause here to avoid
469 # WiiM devices starting playback too early, causing huge initial drift.
470 if is_group_playback:
471 await slimplayer.pause()
472 # if queue is set to single track repeat,
473 # immediately set this track as the next
474 # this prevents race conditions with super short audio clips (on single repeat)
475 # https://github.com/music-assistant/hass-music-assistant/issues/2059
476 if queue and queue.repeat_mode == RepeatMode.ONE:
477 self.mass.call_later(
478 0.2,
479 slimplayer.play_url(
480 url=url,
481 mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
482 metadata=metadata,
483 enqueue=True,
484 send_flush=False,
485 autostart=True,
486 ),
487 )
488
489 def _handle_player_heartbeat(self) -> None:
490 """Process SlimClient elapsed_time update."""
491 if self._attr_playback_state != PlaybackState.PLAYING:
492 # ignore server heartbeats when not playing
493 # Some players keep sending heartbeat with increasing elapsed time
494 # even when paused (e.g. WiiM)
495 return
496 # elapsed time change on the player will be auto picked up
497 # by the player manager.
498 self._attr_elapsed_time = self.client.elapsed_seconds
499 self._attr_elapsed_time_last_updated = time.time()
500
501 # handle sync
502 if self.synced_to:
503 self._handle_sync()
504
505 async def _handle_buffer_ready(self) -> None:
506 """
507 Handle buffer ready event, player has buffered a (new) track.
508
509 Only used when autoplay=0 for coordinated start of synced players.
510 """
511 if self.synced_to:
512 # unpause of sync child is handled by sync master
513 return
514 if not self.group_members:
515 # not a sync group, continue
516 await self.client.unpause_at(self.client.jiffies)
517 return
518 count = 0
519 while count < 40:
520 childs_total = 0
521 childs_ready = 0
522 await asyncio.sleep(0.2)
523 for sync_child in self._get_sync_clients():
524 childs_total += 1
525 if sync_child.state == SlimPlayerState.BUFFER_READY:
526 childs_ready += 1
527 if childs_total == childs_ready:
528 break
529 count += 1
530
531 # all child's ready (or timeout) - start play
532 async with TaskManager(self.mass) as tg:
533 for sync_client in self._get_sync_clients():
534 # NOTE: Officially you should do an unpause_at based on the player timestamp
535 # but I did not have any good results with that.
536 # Instead just start playback on all players and let the sync logic work out
537 # the delays etc.
538 tg.create_task(pause_and_unpause(sync_client, 200))
539
540 async def _handle_player_cli_event(self, event: SlimEvent) -> None:
541 """Process CLI Event."""
542 if not event.data:
543 return
544 # event data is str, not dict
545 # TODO: fix this in the aioslimproto lib
546 event_data = cast("str", event.data)
547 queue = self.mass.player_queues.get_active_queue(self.player_id)
548 if not queue:
549 return
550 if event_data.startswith("button preset_") and event_data.endswith(".single"):
551 preset_id = event_data.split("preset_")[1].split(".")[0]
552 preset_index = int(preset_id) - 1
553 if len(self.client.presets) >= preset_index + 1:
554 preset = self.client.presets[preset_index]
555 await self.mass.player_queues.play_media(queue.queue_id, preset.uri)
556 elif event_data == "button repeat":
557 if queue.repeat_mode == RepeatMode.OFF:
558 repeat_mode = RepeatMode.ONE
559 elif queue.repeat_mode == RepeatMode.ONE:
560 repeat_mode = RepeatMode.ALL
561 else:
562 repeat_mode = RepeatMode.OFF
563 self.mass.player_queues.set_repeat(queue.queue_id, repeat_mode)
564 self.client.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
565 self.client.signal_update()
566 elif event.data == "button shuffle":
567 await self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
568 self.client.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
569 self.client.signal_update()
570 elif event_data in ("button jump_fwd", "button fwd"):
571 await self.mass.player_queues.next(queue.queue_id)
572 elif event_data in ("button jump_rew", "button rew"):
573 await self.mass.player_queues.previous(queue.queue_id)
574 elif event_data.startswith("time "):
575 # seek request
576 _, param = event_data.split(" ", 1)
577 if param.isnumeric():
578 await self.mass.player_queues.seek(queue.queue_id, int(param))
579 self.logger.log(VERBOSE_LOG_LEVEL, "CLI Event: %s", event_data)
580
581 def _handle_sync(self) -> None:
582 """Synchronize audio of a sync slimplayer."""
583 sync_master_id = self.synced_to
584 if not sync_master_id:
585 # we only correct sync members, not the sync master itself
586 return
587 if not self._provider.slimproto or not (
588 sync_master := self._provider.slimproto.get_player(sync_master_id)
589 ):
590 return # just here as a guard as bad things can happen
591
592 if sync_master.state != SlimPlayerState.PLAYING:
593 return
594 if self.client.state != SlimPlayerState.PLAYING:
595 return
596
597 # we collect a few playpoints of the player to determine
598 # average lag/drift so we can adjust accordingly
599 sync_playpoints = self._sync_playpoints
600
601 now = time.time()
602 if now < self._do_not_resync_before:
603 return
604
605 last_playpoint = sync_playpoints[-1] if sync_playpoints else None
606 if last_playpoint and (now - last_playpoint.timestamp) > 10:
607 # last playpoint is too old, invalidate
608 sync_playpoints.clear()
609 if last_playpoint and last_playpoint.sync_master != sync_master.player_id:
610 # this should not happen, but just in case
611 sync_playpoints.clear()
612
613 diff = int(
614 self._provider.get_corrected_elapsed_milliseconds(sync_master)
615 - self._provider.get_corrected_elapsed_milliseconds(self.client)
616 )
617
618 sync_playpoints.append(SyncPlayPoint(now, sync_master.player_id, diff))
619
620 # ignore unexpected spikes
621 if (
622 sync_playpoints
623 and abs(statistics.fmean(abs(x.diff) for x in sync_playpoints) - abs(diff))
624 > DEVIATION_JUMP_IGNORE
625 ):
626 return
627
628 min_req_playpoints = 2 if sync_master.elapsed_seconds < 2 else MIN_REQ_PLAYPOINTS
629 if len(sync_playpoints) < min_req_playpoints:
630 return
631
632 # get the average diff
633 avg_diff = statistics.fmean(x.diff for x in sync_playpoints)
634 delta = int(abs(avg_diff))
635
636 if delta < MIN_DEVIATION_ADJUST:
637 return
638
639 # resync the player by skipping ahead or pause for x amount of (milli)seconds
640 sync_playpoints.clear()
641 self._do_not_resync_before = now + 5
642 if avg_diff > MAX_SKIP_AHEAD_MS:
643 # player lagging behind more than MAX_SKIP_AHEAD_MS,
644 # we need to correct the sync_master
645 self.logger.debug("%s resync: pauseFor %sms", sync_master.name, delta)
646 self.mass.create_task(pause_and_unpause(sync_master, delta))
647 elif avg_diff > 0:
648 # handle player lagging behind, fix with skip_ahead
649 self.logger.debug("%s resync: skipAhead %sms", self.display_name, delta)
650 self.mass.create_task(self.client.skip_over(delta))
651 else:
652 # handle player is drifting too far ahead, use pause_for to adjust
653 self.logger.debug("%s resync: pauseFor %sms", self.display_name, delta)
654 self.mass.create_task(pause_and_unpause(self.client, delta))
655
656 async def _set_preset_items(self) -> None:
657 """Set the presets for a player."""
658 preset_items: list[SlimPreset] = []
659 for preset_index in range(1, 11):
660 if preset_conf := self.mass.config.get_raw_player_config_value(
661 self.player_id, f"preset_{preset_index}"
662 ):
663 try:
664 media_item = await self.mass.music.get_item_by_uri(cast("str", preset_conf))
665 preset_items.append(
666 SlimPreset(
667 uri=media_item.uri,
668 text=media_item.name,
669 icon=(
670 self.mass.metadata.get_image_url(media_item.image)
671 if media_item.image
672 else ""
673 ),
674 )
675 )
676 except MusicAssistantError:
677 # non-existing media item or some other edge case
678 preset_items.append(
679 SlimPreset(
680 uri=f"preset_{preset_index}",
681 text=f"ERROR <preset {preset_index}>",
682 icon="",
683 )
684 )
685 else:
686 break
687 self.client.presets = preset_items
688
689 async def _set_display(self) -> None:
690 """Set the display config for a player."""
691 display_enabled = self.mass.config.get_raw_player_config_value(
692 self.player_id,
693 CONF_ENTRY_DISPLAY.key,
694 CONF_ENTRY_DISPLAY.default_value,
695 )
696 visualization = self.mass.config.get_raw_player_config_value(
697 self.player_id,
698 CONF_ENTRY_VISUALIZATION.key,
699 CONF_ENTRY_VISUALIZATION.default_value,
700 )
701 await self.client.configure_display(
702 visualisation=SlimVisualisationType(visualization), disabled=not display_enabled
703 )
704
705 def _get_sync_clients(self) -> Iterator[SlimClient]:
706 """Get all sync clients for a player."""
707 yield self.client
708 for member_id in self.group_members:
709 if member_id == self.player_id: # â Skip if it's the leader itself
710 continue
711 if self._provider.slimproto and (
712 slimplayer := self._provider.slimproto.get_player(member_id)
713 ):
714 yield slimplayer
715
716
717async def pause_and_unpause(slim_client: SlimClient, pause_duration_ms: int) -> None:
718 """Pause player and schedule unpause after specified duration.
719
720 This is used instead of pause_for because WiiM devices
721 don't properly auto-unpause after pause_for interval.
722 """
723 await slim_client.pause()
724 unpause_timestamp = slim_client.jiffies + pause_duration_ms
725 await slim_client.unpause_at(unpause_timestamp)
726
727
728async def _patched_send_strm( # noqa: PLR0913
729 self: SlimClient,
730 player: SqueezelitePlayer,
731 command: bytes = b"q",
732 autostart: bytes = b"0",
733 codec_details: bytes = b"p1321",
734 threshold: int = 0,
735 spdif: bytes = b"0",
736 trans_duration: int = 0,
737 trans_type: bytes = b"0",
738 flags: int = 0x20,
739 output_threshold: int = 0,
740 replay_gain: int = 0,
741 server_port: int = 0,
742 server_ip: int = 0,
743 httpreq: bytes = b"",
744) -> None:
745 """Create stream request message based on given arguments."""
746 if player._plugin_source_active:
747 threshold = 64 # KB of input buffer data before autostart or notify
748 output_threshold = (
749 1 # amount of output buffer data before playback starts, in tenths of second
750 )
751 data = struct.pack(
752 "!cc5sBcBcBBBLHL",
753 command,
754 autostart,
755 codec_details,
756 threshold,
757 spdif,
758 trans_duration,
759 trans_type,
760 flags,
761 output_threshold,
762 0,
763 replay_gain,
764 server_port,
765 server_ip,
766 )
767 await self.send_frame(b"strm", data + httpreq)
768