music-assistant-server

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