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