/
/
/
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 PlayerConfig is first loaded or 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 else:
421 self._attr_current_media = None
422
423 async def _handle_play_url_for_slimplayer(
424 self,
425 slimplayer: SlimClient,
426 url: str,
427 media: PlayerMedia,
428 enqueue: bool = False,
429 send_flush: bool = True,
430 auto_play: bool = False,
431 is_group_playback: bool = False,
432 ) -> None:
433 """Handle playback of an url on slimproto player(s)."""
434 metadata = {
435 "item_id": media.uri,
436 "title": media.title,
437 "album": media.album,
438 "artist": media.artist,
439 "image_url": media.image_url,
440 "duration": media.duration,
441 "source_id": media.source_id,
442 "queue_item_id": media.queue_item_id,
443 }
444 queue = None
445 if media.source_id and (queue := self.mass.player_queues.get(media.source_id)):
446 self.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
447 self.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
448 source_id = media.source_id or (media.custom_data or {}).get("source_id")
449 self._plugin_source_active = (
450 source_id is not None and self.mass.players.get_plugin_source(source_id) is not None
451 )
452 await slimplayer.play_url(
453 url=url,
454 mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
455 metadata=metadata,
456 enqueue=enqueue,
457 send_flush=send_flush,
458 # if autoplay=False playback will not start automatically
459 # instead 'buffer ready' will be called when the buffer is full
460 # to coordinate a start of multiple synced players
461 autostart=auto_play,
462 )
463 # TODO: When we implement server clock sync, we can remove the pause here
464 # and rely on unpause_at + HEADROOM in the buffer_ready handler. LMS
465 # also does NOT use an explicit pause. For now, we pause here to avoid
466 # WiiM devices starting playback too early, causing huge initial drift.
467 if is_group_playback:
468 await slimplayer.pause()
469 # if queue is set to single track repeat,
470 # immediately set this track as the next
471 # this prevents race conditions with super short audio clips (on single repeat)
472 # https://github.com/music-assistant/hass-music-assistant/issues/2059
473 if queue and queue.repeat_mode == RepeatMode.ONE:
474 self.mass.call_later(
475 0.2,
476 slimplayer.play_url(
477 url=url,
478 mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
479 metadata=metadata,
480 enqueue=True,
481 send_flush=False,
482 autostart=True,
483 ),
484 )
485
486 def _handle_player_heartbeat(self) -> None:
487 """Process SlimClient elapsed_time update."""
488 if self._attr_playback_state != PlaybackState.PLAYING:
489 # ignore server heartbeats when not playing
490 # Some players keep sending heartbeat with increasing elapsed time
491 # even when paused (e.g. WiiM)
492 return
493 # elapsed time change on the player will be auto picked up
494 # by the player manager.
495 self._attr_elapsed_time = self.client.elapsed_seconds
496 self._attr_elapsed_time_last_updated = time.time()
497
498 # handle sync
499 if self.synced_to:
500 self._handle_sync()
501
502 async def _handle_buffer_ready(self) -> None:
503 """
504 Handle buffer ready event, player has buffered a (new) track.
505
506 Only used when autoplay=0 for coordinated start of synced players.
507 """
508 if self.synced_to:
509 # unpause of sync child is handled by sync master
510 return
511 if not self.group_members:
512 # not a sync group, continue
513 await self.client.unpause_at(self.client.jiffies)
514 return
515 count = 0
516 while count < 40:
517 childs_total = 0
518 childs_ready = 0
519 await asyncio.sleep(0.2)
520 for sync_child in self._get_sync_clients():
521 childs_total += 1
522 if sync_child.state == SlimPlayerState.BUFFER_READY:
523 childs_ready += 1
524 if childs_total == childs_ready:
525 break
526 count += 1
527
528 # all child's ready (or timeout) - start play
529 async with TaskManager(self.mass) as tg:
530 for sync_client in self._get_sync_clients():
531 # NOTE: Officially you should do an unpause_at based on the player timestamp
532 # but I did not have any good results with that.
533 # Instead just start playback on all players and let the sync logic work out
534 # the delays etc.
535 tg.create_task(pause_and_unpause(sync_client, 200))
536
537 async def _handle_player_cli_event(self, event: SlimEvent) -> None:
538 """Process CLI Event."""
539 if not event.data:
540 return
541 # event data is str, not dict
542 # TODO: fix this in the aioslimproto lib
543 event_data = cast("str", event.data)
544 queue = self.mass.player_queues.get_active_queue(self.player_id)
545 if not queue:
546 return
547 if event_data.startswith("button preset_") and event_data.endswith(".single"):
548 preset_id = event_data.split("preset_")[1].split(".")[0]
549 preset_index = int(preset_id) - 1
550 if len(self.client.presets) >= preset_index + 1:
551 preset = self.client.presets[preset_index]
552 await self.mass.player_queues.play_media(queue.queue_id, preset.uri)
553 elif event_data == "button repeat":
554 if queue.repeat_mode == RepeatMode.OFF:
555 repeat_mode = RepeatMode.ONE
556 elif queue.repeat_mode == RepeatMode.ONE:
557 repeat_mode = RepeatMode.ALL
558 else:
559 repeat_mode = RepeatMode.OFF
560 self.mass.player_queues.set_repeat(queue.queue_id, repeat_mode)
561 self.client.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
562 self.client.signal_update()
563 elif event.data == "button shuffle":
564 await self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
565 self.client.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
566 self.client.signal_update()
567 elif event_data in ("button jump_fwd", "button fwd"):
568 await self.mass.player_queues.next(queue.queue_id)
569 elif event_data in ("button jump_rew", "button rew"):
570 await self.mass.player_queues.previous(queue.queue_id)
571 elif event_data.startswith("time "):
572 # seek request
573 _, param = event_data.split(" ", 1)
574 if param.isnumeric():
575 await self.mass.player_queues.seek(queue.queue_id, int(param))
576 self.logger.log(VERBOSE_LOG_LEVEL, "CLI Event: %s", event_data)
577
578 def _handle_sync(self) -> None:
579 """Synchronize audio of a sync slimplayer."""
580 sync_master_id = self.synced_to
581 if not sync_master_id:
582 # we only correct sync members, not the sync master itself
583 return
584 if not self._provider.slimproto or not (
585 sync_master := self._provider.slimproto.get_player(sync_master_id)
586 ):
587 return # just here as a guard as bad things can happen
588
589 if sync_master.state != SlimPlayerState.PLAYING:
590 return
591 if self.client.state != SlimPlayerState.PLAYING:
592 return
593
594 # we collect a few playpoints of the player to determine
595 # average lag/drift so we can adjust accordingly
596 sync_playpoints = self._sync_playpoints
597
598 now = time.time()
599 if now < self._do_not_resync_before:
600 return
601
602 last_playpoint = sync_playpoints[-1] if sync_playpoints else None
603 if last_playpoint and (now - last_playpoint.timestamp) > 10:
604 # last playpoint is too old, invalidate
605 sync_playpoints.clear()
606 if last_playpoint and last_playpoint.sync_master != sync_master.player_id:
607 # this should not happen, but just in case
608 sync_playpoints.clear()
609
610 diff = int(
611 self._provider.get_corrected_elapsed_milliseconds(sync_master)
612 - self._provider.get_corrected_elapsed_milliseconds(self.client)
613 )
614
615 sync_playpoints.append(SyncPlayPoint(now, sync_master.player_id, diff))
616
617 # ignore unexpected spikes
618 if (
619 sync_playpoints
620 and abs(statistics.fmean(abs(x.diff) for x in sync_playpoints) - abs(diff))
621 > DEVIATION_JUMP_IGNORE
622 ):
623 return
624
625 min_req_playpoints = 2 if sync_master.elapsed_seconds < 2 else MIN_REQ_PLAYPOINTS
626 if len(sync_playpoints) < min_req_playpoints:
627 return
628
629 # get the average diff
630 avg_diff = statistics.fmean(x.diff for x in sync_playpoints)
631 delta = int(abs(avg_diff))
632
633 if delta < MIN_DEVIATION_ADJUST:
634 return
635
636 # resync the player by skipping ahead or pause for x amount of (milli)seconds
637 sync_playpoints.clear()
638 self._do_not_resync_before = now + 5
639 if avg_diff > MAX_SKIP_AHEAD_MS:
640 # player lagging behind more than MAX_SKIP_AHEAD_MS,
641 # we need to correct the sync_master
642 self.logger.debug("%s resync: pauseFor %sms", sync_master.name, delta)
643 self.mass.create_task(pause_and_unpause(sync_master, delta))
644 elif avg_diff > 0:
645 # handle player lagging behind, fix with skip_ahead
646 self.logger.debug("%s resync: skipAhead %sms", self.display_name, delta)
647 self.mass.create_task(self.client.skip_over(delta))
648 else:
649 # handle player is drifting too far ahead, use pause_for to adjust
650 self.logger.debug("%s resync: pauseFor %sms", self.display_name, delta)
651 self.mass.create_task(pause_and_unpause(self.client, delta))
652
653 async def _set_preset_items(self) -> None:
654 """Set the presets for a player."""
655 preset_items: list[SlimPreset] = []
656 for preset_index in range(1, 11):
657 if preset_conf := self.mass.config.get_raw_player_config_value(
658 self.player_id, f"preset_{preset_index}"
659 ):
660 try:
661 media_item = await self.mass.music.get_item_by_uri(cast("str", preset_conf))
662 preset_items.append(
663 SlimPreset(
664 uri=media_item.uri,
665 text=media_item.name,
666 icon=(
667 self.mass.metadata.get_image_url(media_item.image)
668 if media_item.image
669 else ""
670 ),
671 )
672 )
673 except MusicAssistantError:
674 # non-existing media item or some other edge case
675 preset_items.append(
676 SlimPreset(
677 uri=f"preset_{preset_index}",
678 text=f"ERROR <preset {preset_index}>",
679 icon="",
680 )
681 )
682 else:
683 break
684 self.client.presets = preset_items
685
686 async def _set_display(self) -> None:
687 """Set the display config for a player."""
688 display_enabled = self.mass.config.get_raw_player_config_value(
689 self.player_id,
690 CONF_ENTRY_DISPLAY.key,
691 CONF_ENTRY_DISPLAY.default_value,
692 )
693 visualization = self.mass.config.get_raw_player_config_value(
694 self.player_id,
695 CONF_ENTRY_VISUALIZATION.key,
696 CONF_ENTRY_VISUALIZATION.default_value,
697 )
698 await self.client.configure_display(
699 visualisation=SlimVisualisationType(visualization), disabled=not display_enabled
700 )
701
702 def _get_sync_clients(self) -> Iterator[SlimClient]:
703 """Get all sync clients for a player."""
704 yield self.client
705 for member_id in self.group_members:
706 if member_id == self.player_id: # â Skip if it's the leader itself
707 continue
708 if self._provider.slimproto and (
709 slimplayer := self._provider.slimproto.get_player(member_id)
710 ):
711 yield slimplayer
712
713
714async def pause_and_unpause(slim_client: SlimClient, pause_duration_ms: int) -> None:
715 """Pause player and schedule unpause after specified duration.
716
717 This is used instead of pause_for because WiiM devices
718 don't properly auto-unpause after pause_for interval.
719 """
720 await slim_client.pause()
721 unpause_timestamp = slim_client.jiffies + pause_duration_ms
722 await slim_client.unpause_at(unpause_timestamp)
723
724
725async def _patched_send_strm( # noqa: PLR0913
726 self: SlimClient,
727 player: SqueezelitePlayer,
728 command: bytes = b"q",
729 autostart: bytes = b"0",
730 codec_details: bytes = b"p1321",
731 threshold: int = 0,
732 spdif: bytes = b"0",
733 trans_duration: int = 0,
734 trans_type: bytes = b"0",
735 flags: int = 0x20,
736 output_threshold: int = 0,
737 replay_gain: int = 0,
738 server_port: int = 0,
739 server_ip: int = 0,
740 httpreq: bytes = b"",
741) -> None:
742 """Create stream request message based on given arguments."""
743 if player._plugin_source_active:
744 threshold = 64 # KB of input buffer data before autostart or notify
745 output_threshold = (
746 1 # amount of output buffer data before playback starts, in tenths of second
747 )
748 data = struct.pack(
749 "!cc5sBcBcBBBLHL",
750 command,
751 autostart,
752 codec_details,
753 threshold,
754 spdif,
755 trans_duration,
756 trans_type,
757 flags,
758 output_threshold,
759 0,
760 replay_gain,
761 server_port,
762 server_ip,
763 )
764 await self.send_frame(b"strm", data + httpreq)
765