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