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