From 30c4212a17cff0b0d82bb67cf4fe538b2c357c47 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 4 Dec 2025 18:23:49 +0100 Subject: [PATCH] Update Implemented Sendspin Version with included Volume Support (#2732) --- music_assistant/controllers/config.py | 9 + music_assistant/providers/resonate/icon.svg | 11 - .../providers/resonate/icon_monochrome.svg | 62 ---- .../providers/resonate/manifest.json | 9 - .../{resonate => sendspin}/__init__.py | 8 +- music_assistant/providers/sendspin/icon.svg | 16 + .../providers/sendspin/icon_dark.svg | 16 + .../providers/sendspin/icon_monochrome.svg | 16 + .../providers/sendspin/manifest.json | 10 + .../{resonate => sendspin}/player.py | 290 ++++++++++++------ .../{resonate => sendspin}/provider.py | 32 +- .../timed_client_stream.py | 6 +- requirements_all.txt | 2 +- 13 files changed, 280 insertions(+), 207 deletions(-) delete mode 100644 music_assistant/providers/resonate/icon.svg delete mode 100644 music_assistant/providers/resonate/icon_monochrome.svg delete mode 100644 music_assistant/providers/resonate/manifest.json rename music_assistant/providers/{resonate => sendspin}/__init__.py (85%) create mode 100644 music_assistant/providers/sendspin/icon.svg create mode 100644 music_assistant/providers/sendspin/icon_dark.svg create mode 100644 music_assistant/providers/sendspin/icon_monochrome.svg create mode 100644 music_assistant/providers/sendspin/manifest.json rename music_assistant/providers/{resonate => sendspin}/player.py (65%) rename music_assistant/providers/{resonate => sendspin}/provider.py (73%) rename music_assistant/providers/{resonate => sendspin}/timed_client_stream.py (98%) diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 8c8c9447..34d8039d 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -1281,6 +1281,15 @@ class ConfigController: provider_config["instance_id"] = "universal_group" self._data[CONF_PROVIDERS]["universal_group"] = provider_config + # Migrate resonate provider to sendspin (renamed in 2.7 beta 19) + for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()): + if provider_config.get("domain") == "resonate": + self._data[CONF_PROVIDERS].pop(instance_id, None) + provider_config["domain"] = "sendspin" + provider_config["instance_id"] = "sendspin" + self._data[CONF_PROVIDERS]["sendspin"] = provider_config + changed = True + # Migrate the crossfade setting into Smart Fade Mode = 'crossfade' for player_config in self._data.get(CONF_PLAYERS, {}).values(): if not (values := player_config.get("values")): diff --git a/music_assistant/providers/resonate/icon.svg b/music_assistant/providers/resonate/icon.svg deleted file mode 100644 index 845920ca..00000000 --- a/music_assistant/providers/resonate/icon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/music_assistant/providers/resonate/icon_monochrome.svg b/music_assistant/providers/resonate/icon_monochrome.svg deleted file mode 100644 index 8b01ceee..00000000 --- a/music_assistant/providers/resonate/icon_monochrome.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - diff --git a/music_assistant/providers/resonate/manifest.json b/music_assistant/providers/resonate/manifest.json deleted file mode 100644 index ca71e276..00000000 --- a/music_assistant/providers/resonate/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "player", - "domain": "resonate", - "stage": "alpha", - "name": "Resonate (WIP)", - "description": "Resonate (working title) is the next generation streaming protocol built by the Open Home Foundation. Follow the development on Discord to see how you can get involved.", - "codeowners": ["@music-assistant"], - "requirements": ["aioresonate==0.13.1"] -} diff --git a/music_assistant/providers/resonate/__init__.py b/music_assistant/providers/sendspin/__init__.py similarity index 85% rename from music_assistant/providers/resonate/__init__.py rename to music_assistant/providers/sendspin/__init__.py index 0cfc2b59..31686d48 100644 --- a/music_assistant/providers/resonate/__init__.py +++ b/music_assistant/providers/sendspin/__init__.py @@ -1,14 +1,14 @@ """ -Player Provider for the Resonate Audio Protocol. +Player Provider for the Sendspin Audio Protocol. -https://github.com/Resonate-Protocol/spec +https://github.com/Sendspin-Protocol/spec """ from __future__ import annotations from typing import TYPE_CHECKING -from .provider import ResonateProvider +from .provider import SendspinProvider if TYPE_CHECKING: from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig @@ -22,7 +22,7 @@ async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - return ResonateProvider(mass, manifest, config) + return SendspinProvider(mass, manifest, config) async def get_config_entries( diff --git a/music_assistant/providers/sendspin/icon.svg b/music_assistant/providers/sendspin/icon.svg new file mode 100644 index 00000000..0c1f0dab --- /dev/null +++ b/music_assistant/providers/sendspin/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/music_assistant/providers/sendspin/icon_dark.svg b/music_assistant/providers/sendspin/icon_dark.svg new file mode 100644 index 00000000..73a7c4f2 --- /dev/null +++ b/music_assistant/providers/sendspin/icon_dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/music_assistant/providers/sendspin/icon_monochrome.svg b/music_assistant/providers/sendspin/icon_monochrome.svg new file mode 100644 index 00000000..720e5664 --- /dev/null +++ b/music_assistant/providers/sendspin/icon_monochrome.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/music_assistant/providers/sendspin/manifest.json b/music_assistant/providers/sendspin/manifest.json new file mode 100644 index 00000000..7a795450 --- /dev/null +++ b/music_assistant/providers/sendspin/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "player", + "domain": "sendspin", + "stage": "alpha", + "name": "Sendspin", + "description": "Sendspin is an audio playback, control and synchronization protocol developed by the Open Home Foundation and is the native playback protocol built into Music Assistant, used for playback to supported clients like the Music Assistant Web interface, supported (mobile) clients and supported hardware", + "codeowners": ["@music-assistant"], + "requirements": ["aiosendspin==1.0.0"], + "builtin": true +} diff --git a/music_assistant/providers/resonate/player.py b/music_assistant/providers/sendspin/player.py similarity index 65% rename from music_assistant/providers/resonate/player.py rename to music_assistant/providers/sendspin/player.py index b6e082b5..0d100a04 100644 --- a/music_assistant/providers/resonate/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -1,4 +1,4 @@ -"""Resonate Player implementation.""" +"""Sendspin Player implementation.""" from __future__ import annotations @@ -8,31 +8,32 @@ from collections.abc import AsyncGenerator, Callable from io import BytesIO from typing import TYPE_CHECKING, cast -from aioresonate.models import MediaCommand -from aioresonate.models.types import PlaybackStateType -from aioresonate.models.types import RepeatMode as ResonateRepeatMode -from aioresonate.server import AudioFormat as ResonateAudioFormat -from aioresonate.server import ( +from aiosendspin.models import MediaCommand +from aiosendspin.models.types import ArtworkSource, PlaybackStateType +from aiosendspin.models.types import RepeatMode as SendspinRepeatMode +from aiosendspin.server import AudioFormat as SendspinAudioFormat +from aiosendspin.server import ( ClientEvent, GroupCommandEvent, GroupEvent, GroupStateChangedEvent, + SendspinGroup, VolumeChangedEvent, ) -from aioresonate.server.client import DisconnectBehaviour -from aioresonate.server.events import ClientGroupChangedEvent -from aioresonate.server.group import ( +from aiosendspin.server.client import DisconnectBehaviour +from aiosendspin.server.events import ClientGroupChangedEvent +from aiosendspin.server.group import ( GroupDeletedEvent, GroupMemberAddedEvent, GroupMemberRemovedEvent, ) -from aioresonate.server.metadata import Metadata -from aioresonate.server.stream import AudioCodec, MediaStream -from music_assistant_models.config_entries import ConfigEntry, ConfigValueType +from aiosendspin.server.metadata import Metadata +from aiosendspin.server.stream import AudioCodec, MediaStream from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( ContentType, EventType, + ImageType, PlaybackState, PlayerFeature, PlayerType, @@ -44,7 +45,6 @@ from PIL import Image from music_assistant.constants import ( CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_OUTPUT_CODEC, CONF_OUTPUT_CODEC, INTERNAL_PCM_FORMAT, ) @@ -53,17 +53,33 @@ from music_assistant.models.player import Player, PlayerMedia from .timed_client_stream import TimedClientStream +# Supported group commands for Sendspin players +SUPPORTED_GROUP_COMMANDS = [ + MediaCommand.PLAY, + MediaCommand.PAUSE, + MediaCommand.STOP, + MediaCommand.NEXT, + MediaCommand.PREVIOUS, + MediaCommand.REPEAT_OFF, + MediaCommand.REPEAT_ONE, + MediaCommand.REPEAT_ALL, + MediaCommand.SHUFFLE, + MediaCommand.UNSHUFFLE, +] + if TYPE_CHECKING: - from aioresonate.server.client import ResonateClient + from aiosendspin.server.client import SendspinClient + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType from music_assistant_models.event import MassEvent + from music_assistant_models.queue_item import QueueItem - from .provider import ResonateProvider + from .provider import SendspinProvider class MusicAssistantMediaStream(MediaStream): """MediaStream implementation for Music Assistant with per-player DSP support.""" - player_instance: ResonatePlayer + player_instance: SendspinPlayer internal_format: AudioFormat output_format: AudioFormat @@ -71,8 +87,8 @@ class MusicAssistantMediaStream(MediaStream): self, *, main_channel_source: AsyncGenerator[bytes, None], - main_channel_format: ResonateAudioFormat, - player_instance: ResonatePlayer, + main_channel_format: SendspinAudioFormat, + player_instance: SendspinPlayer, internal_format: AudioFormat, output_format: AudioFormat, ) -> None: @@ -82,7 +98,7 @@ class MusicAssistantMediaStream(MediaStream): Args: main_channel_source: Audio source generator for the main channel. main_channel_format: Audio format for the main channel (includes codec). - player_instance: The ResonatePlayer instance for accessing mass and streams. + player_instance: The SendspinPlayer instance for accessing mass and streams. internal_format: Internal processing format (float32 for headroom). output_format: Output PCM format (16-bit for player output). """ @@ -97,9 +113,9 @@ class MusicAssistantMediaStream(MediaStream): async def player_channel( self, player_id: str, - preferred_format: ResonateAudioFormat | None = None, + preferred_format: SendspinAudioFormat | None = None, position_us: int = 0, - ) -> tuple[AsyncGenerator[bytes, None], ResonateAudioFormat, int] | None: + ) -> tuple[AsyncGenerator[bytes, None], SendspinAudioFormat, int] | None: """ Get a player-specific audio stream with per-player DSP. @@ -136,7 +152,7 @@ class MusicAssistantMediaStream(MediaStream): filter_params=filter_params, ) - # Convert position from seconds to microseconds for aioresonate API + # Convert position from seconds to microseconds for aiosendspin API actual_position_us = int(actual_position * 1_000_000) # Return actual position in microseconds relative to main_stream start @@ -147,7 +163,7 @@ class MusicAssistantMediaStream(MediaStream): ) return ( stream_gen, - ResonateAudioFormat( + SendspinAudioFormat( sample_rate=self.output_format.sample_rate, bit_depth=self.output_format.bit_depth, channels=self.output_format.channels, @@ -157,38 +173,42 @@ class MusicAssistantMediaStream(MediaStream): ) -class ResonatePlayer(Player): - """A resonate audio player in Music Assistant.""" +class SendspinPlayer(Player): + """A sendspin audio player in Music Assistant.""" - api: ResonateClient + api: SendspinClient unsub_event_cb: Callable[[], None] unsub_group_event_cb: Callable[[], None] last_sent_artwork_url: str | None = None + last_sent_artist_artwork_url: str | None = None _playback_task: asyncio.Task[None] | None = None timed_client_stream: TimedClientStream | None = None - def __init__(self, provider: ResonateProvider, player_id: str) -> None: + def __init__(self, provider: SendspinProvider, player_id: str) -> None: """Initialize the Player.""" super().__init__(provider, player_id) - resonate_client = provider.server_api.get_client(player_id) - assert resonate_client is not None - self.api = resonate_client + sendspin_client = provider.server_api.get_client(player_id) + assert sendspin_client is not None + self.api = sendspin_client self.api.disconnect_behaviour = DisconnectBehaviour.STOP - self.unsub_event_cb = resonate_client.add_event_listener(self.event_cb) - self.unsub_group_event_cb = resonate_client.group.add_event_listener(self.group_event_cb) + self.unsub_event_cb = sendspin_client.add_event_listener(self.event_cb) + self.unsub_group_event_cb = sendspin_client.group.add_event_listener(self.group_event_cb) + sendspin_client.group.set_supported_commands(SUPPORTED_GROUP_COMMANDS) self.logger = self.provider.logger.getChild(player_id) # init some static variables - self._attr_name = resonate_client.name + self._attr_name = sendspin_client.name self._attr_type = PlayerType.PLAYER self._attr_supported_features = { PlayerFeature.SET_MEMBERS, PlayerFeature.MULTI_DEVICE_DSP, + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, } self._attr_can_group_with = {provider.lookup_key} self._attr_power_control = PLAYER_CONTROL_NONE self._attr_device_info = DeviceInfo() - if player_client := resonate_client.player: + if player_client := sendspin_client.player: self._attr_volume_level = player_client.volume self._attr_volume_muted = player_client.muted self._attr_available = True @@ -199,8 +219,8 @@ class ResonatePlayer(Player): ) ) - async def event_cb(self, event: ClientEvent) -> None: - """Event callback registered to the resonate server.""" + async def event_cb(self, client: SendspinClient, event: ClientEvent) -> None: + """Event callback registered to the sendspin server.""" self.logger.debug("Received PlayerEvent: %s", event) match event: case VolumeChangedEvent(volume=volume, muted=muted): @@ -218,40 +238,54 @@ class ResonatePlayer(Player): self._attr_playback_state = PlaybackState.PAUSED case PlaybackStateType.STOPPED: self._attr_playback_state = PlaybackState.IDLE + # Update in case this is a newly created group + new_group.set_supported_commands(SUPPORTED_GROUP_COMMANDS) + # GroupMemberAddedEvent or GroupMemberRemovedEvent will be fired before this + # so group members are already up to date at this point + if self.synced_to is None: + # We are the leader, stop on disconnect + self.api.disconnect_behaviour = DisconnectBehaviour.STOP + else: + self.api.disconnect_behaviour = DisconnectBehaviour.UNGROUP self.update_state() - async def group_event_cb(self, event: GroupEvent) -> None: - """Event callback registered to the resonate group this player belongs to.""" + async def _handle_group_command(self, command: MediaCommand) -> None: + """Handle a group command from aiosendspin.""" + queue = self.mass.player_queues.get_active_queue(self.player_id) + match command: + case MediaCommand.PLAY: + await self.mass.players.cmd_play(self.player_id) + case MediaCommand.PAUSE: + await self.mass.players.cmd_pause(self.player_id) + case MediaCommand.STOP: + await self.mass.players.cmd_stop(self.player_id) + case MediaCommand.NEXT: + await self.mass.players.cmd_next_track(self.player_id) + case MediaCommand.PREVIOUS: + await self.mass.players.cmd_previous_track(self.player_id) + case MediaCommand.REPEAT_OFF if queue: + self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.OFF) + case MediaCommand.REPEAT_ONE if queue: + self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ONE) + case MediaCommand.REPEAT_ALL if queue: + self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL) + case MediaCommand.SHUFFLE if queue: + self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True) + case MediaCommand.UNSHUFFLE if queue: + self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False) + + async def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None: + """Event callback registered to the sendspin group this player belongs to.""" if self.synced_to is not None: - # Only handle group events as the leader - return + # Only handle group events as the leader, except for GroupMemberRemovedEvent + if not isinstance(event, GroupMemberRemovedEvent): + return self.logger.debug("Received GroupEvent: %s", event) match event: - case GroupCommandEvent(command=command, volume=volume, mute=mute): + case GroupCommandEvent(command=command): self.logger.debug("Group command received: %s", command) - match command: - case MediaCommand.PLAY: - await self.mass.players.cmd_play(self.player_id) - case MediaCommand.PAUSE: - await self.mass.players.cmd_pause(self.player_id) - case MediaCommand.STOP: - await self.mass.players.cmd_stop(self.player_id) - case MediaCommand.NEXT: - await self.mass.players.cmd_next_track(self.player_id) - case MediaCommand.PREVIOUS: - await self.mass.players.cmd_previous_track(self.player_id) - case MediaCommand.SEEK: - raise NotImplementedError("TODO: not supported by spec yet") - case MediaCommand.VOLUME: - assert volume is not None - await self.mass.players.cmd_group_volume(self.player_id, volume) - case MediaCommand.MUTE: - assert mute is not None - for member in self.mass.players.iter_group_members( - self, active_only=True, exclude_self=True - ): - await member.volume_mute(mute) + await self._handle_group_command(command) case GroupStateChangedEvent(state=state): self.logger.debug("Group state changed to: %s", state) match state: @@ -271,7 +305,25 @@ class ResonatePlayer(Player): self.update_state() case GroupMemberRemovedEvent(client_id=client_id): self.logger.debug("Group member removed: %s", client_id) - if client_id in self._attr_group_members: + if client_id == self.player_id: + if len(self._attr_group_members) > 0: + # We were just removed as a leader: + # 1. stop playback on the old group + await group.stop() + # 2. clear our members (since we are now alone) + group_members = [ + member for member in self._attr_group_members if member != client_id + ] + self._attr_group_members = [] + # 3. assign new leader if there are members left + if len(group_members) > 0 and ( + new_leader := self.mass.players.get(group_members[0]) + ): + new_leader._attr_group_members = group_members[1:] + new_leader.update_state() + self.update_state() + elif client_id in self._attr_group_members: + # Someone else left our group self._attr_group_members.remove(client_id) self.update_state() case GroupDeletedEvent(): @@ -351,7 +403,7 @@ class ResonatePlayer(Player): ) # Setup the main channel subscription - # aioresonate only really supports 16-bit for now TODO: upgrade later to 32-bit + # aiosendspin only really supports 16-bit for now TODO: upgrade later to 32-bit main_channel_gen, main_position = await self.timed_client_stream.get_stream( output_format=pcm_format, filter_params=None, # TODO: this should probably still include the safety limiter @@ -359,7 +411,7 @@ class ResonatePlayer(Player): assert main_position == 0.0 # first subscriber, should be zero media_stream = MusicAssistantMediaStream( main_channel_source=main_channel_gen, - main_channel_format=ResonateAudioFormat( + main_channel_format=SendspinAudioFormat( sample_rate=pcm_format.sample_rate, bit_depth=pcm_format.bit_depth, channels=pcm_format.channels, @@ -392,18 +444,75 @@ class ResonatePlayer(Player): ) for player_id in player_ids_to_remove or []: player = self.mass.players.get(player_id, True) - player = cast("ResonatePlayer", player) # For type checking + player = cast("SendspinPlayer", player) # For type checking await self.api.group.remove_client(player.api) - player.api.disconnect_behaviour = DisconnectBehaviour.STOP for player_id in player_ids_to_add or []: player = self.mass.players.get(player_id, True) - player = cast("ResonatePlayer", player) # For type checking - player.api.disconnect_behaviour = DisconnectBehaviour.UNGROUP + player = cast("SendspinPlayer", player) # For type checking await self.api.group.add_client(player.api) # self.group_members will be updated by the group event callback + async def _send_album_artwork(self, current_item: QueueItem) -> str | None: + """ + Send album artwork to the sendspin group. + + Args: + current_item: The current queue item. + """ + artwork_url = None + if current_item.image is not None: + artwork_url = self.mass.metadata.get_image_url(current_item.image) + + if artwork_url != self.last_sent_artwork_url: + # Image changed, resend the artwork + self.last_sent_artwork_url = artwork_url + if artwork_url is not None and current_item.media_item is not None: + image_data = await self.mass.metadata.get_image_data_for_item( + current_item.media_item + ) + if image_data is not None: + image = await asyncio.to_thread(Image.open, BytesIO(image_data)) + await self.api.group.set_media_art(image, source=ArtworkSource.ALBUM) + else: + # Clear artwork if none available + await self.api.group.set_media_art(None, source=ArtworkSource.ALBUM) + + return artwork_url + + async def _send_artist_artwork(self, current_item: QueueItem) -> None: + """ + Send artist artwork to the sendspin group. + + Args: + current_item: The current queue item. + """ + # Extract primary artist if available + artist_artwork_url = None + if current_item.media_item is not None and hasattr(current_item.media_item, "artists"): + artists = getattr(current_item.media_item, "artists", None) + if artists and len(artists) > 0: + primary_artist = artists[0] + if hasattr(primary_artist, "image"): + artist_image = getattr(primary_artist, "image", None) + if artist_image is not None: + artist_artwork_url = self.mass.metadata.get_image_url(artist_image) + + if artist_artwork_url != self.last_sent_artist_artwork_url: + # Artist image changed, resend the artwork + self.last_sent_artist_artwork_url = artist_artwork_url + if artist_artwork_url is not None: + artist_image_data = await self.mass.metadata.get_image_data_for_item( + primary_artist, img_type=ImageType.THUMB + ) + if artist_image_data is not None: + artist_image = await asyncio.to_thread(Image.open, BytesIO(artist_image_data)) + await self.api.group.set_media_art(artist_image, source=ArtworkSource.ARTIST) + else: + # Clear artist artwork if none available + await self.api.group.set_media_art(None, source=ArtworkSource.ARTIST) + async def _on_queue_update(self, event: MassEvent) -> None: - """Extract and send current media metadata to resonate players on queue updates.""" + """Extract and send current media metadata to sendspin players on queue updates.""" queue = self.mass.player_queues.get_active_queue(self.player_id) if not queue or not queue.current_item: return @@ -438,28 +547,17 @@ class ResonatePlayer(Player): if _track_number := getattr(media_item, "track_number", None): track = _track_number - if current_item.image is not None: - artwork_url = self.mass.metadata.get_image_url(current_item.image) - - if artwork_url != self.last_sent_artwork_url: - # Image changed, resend the artwork - self.last_sent_artwork_url = artwork_url - if artwork_url is not None and current_item.media_item is not None: - image_data = await self.mass.metadata.get_image_data_for_item( - current_item.media_item - ) - if image_data is not None: - image = await asyncio.to_thread(Image.open, BytesIO(image_data)) - await self.api.group.set_media_art(image) - # TODO: null media art if not set? + # Send album and artist artwork + artwork_url = await self._send_album_artwork(current_item) + await self._send_artist_artwork(current_item) track_duration = current_item.duration - repeat = ResonateRepeatMode.OFF + repeat = SendspinRepeatMode.OFF if queue.repeat_mode == RepeatMode.ALL: - repeat = ResonateRepeatMode.ALL + repeat = SendspinRepeatMode.ALL elif queue.repeat_mode == RepeatMode.ONE: - repeat = ResonateRepeatMode.ONE + repeat = SendspinRepeatMode.ONE shuffle = queue.shuffle_enabled @@ -471,8 +569,9 @@ class ResonatePlayer(Player): artwork_url=artwork_url, year=year, track=track, - track_duration=track_duration, - playback_speed=1, + track_duration=track_duration * 1000 if track_duration is not None else None, + track_progress=int(queue.corrected_elapsed_time * 1000), + playback_speed=1000, repeat=repeat, shuffle=shuffle, ) @@ -490,17 +589,6 @@ class ResonatePlayer(Player): return [ *default_entries, CONF_ENTRY_FLOW_MODE_ENFORCED, - ConfigEntry.from_dict( - { - **CONF_ENTRY_OUTPUT_CODEC.to_dict(), - "default_value": "pcm", - "options": [ - {"title": "PCM (lossless, uncompressed)", "value": "pcm"}, - {"title": "FLAC (lossless, compressed)", "value": "flac"}, - {"title": "OPUS (lossy)", "value": "opus"}, - ], - } - ), ] async def on_unload(self) -> None: diff --git a/music_assistant/providers/resonate/provider.py b/music_assistant/providers/sendspin/provider.py similarity index 73% rename from music_assistant/providers/resonate/provider.py rename to music_assistant/providers/sendspin/provider.py index 7e934fa4..a7f0cc98 100644 --- a/music_assistant/providers/resonate/provider.py +++ b/music_assistant/providers/sendspin/provider.py @@ -1,57 +1,57 @@ -"""Player Provider for Resonate.""" +"""Player Provider for Sendspin.""" from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING, cast -from aioresonate.server import ClientAddedEvent, ClientRemovedEvent, ResonateEvent, ResonateServer +from aiosendspin.server import ClientAddedEvent, ClientRemovedEvent, SendspinEvent, SendspinServer from music_assistant_models.enums import ProviderFeature from music_assistant.mass import MusicAssistant from music_assistant.models.player_provider import PlayerProvider -from music_assistant.providers.resonate.player import ResonatePlayer +from music_assistant.providers.sendspin.player import SendspinPlayer if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest -class ResonateProvider(PlayerProvider): - """Player Provider for Resonate.""" +class SendspinProvider(PlayerProvider): + """Player Provider for Sendspin.""" - server_api: ResonateServer + server_api: SendspinServer unregister_cbs: list[Callable[[], None]] def __init__( self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> None: - """Initialize a new Resonate player provider.""" + """Initialize a new Sendspin player provider.""" super().__init__(mass, manifest, config) - self.server_api = ResonateServer( + self.server_api = SendspinServer( self.mass.loop, mass.server_id, "Music Assistant", self.mass.http_session ) self.unregister_cbs = [ self.server_api.add_event_listener(self.event_cb), # For the web player self.mass.webserver.register_dynamic_route( - "/resonate", self.server_api.on_client_connect + "/sendspin", self.server_api.on_client_connect ), ] - async def event_cb(self, event: ResonateEvent) -> None: - """Event callback registered to the resonate server.""" - self.logger.debug("Received ResonateEvent: %s", event) + async def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None: + """Event callback registered to the sendspin server.""" + self.logger.debug("Received SendspinEvent: %s", event) match event: case ClientAddedEvent(client_id): - player = ResonatePlayer(self, client_id) + player = SendspinPlayer(self, client_id) self.logger.debug("Client %s connected", client_id) await self.mass.players.register(player) case ClientRemovedEvent(client_id): self.logger.debug("Client %s disconnected", client_id) await self.mass.players.unregister(client_id) case _: - self.logger.error("Unknown resonate event: %s", event) + self.logger.error("Unknown sendspin event: %s", event) @property def supported_features(self) -> set[ProviderFeature]: @@ -63,7 +63,7 @@ class ResonateProvider(PlayerProvider): async def loaded_in_mass(self) -> None: """Call after the provider has been loaded.""" await super().loaded_in_mass() - # Start server for handling incoming Resonate connections from clients + # Start server for handling incoming Sendspin connections from clients # and mDNS discovery of new clients await self.server_api.start_server( port=8927, @@ -78,7 +78,7 @@ class ResonateProvider(PlayerProvider): Called when provider is deregistered (e.g. MA exiting or config reloading). is_removed will be set to True when the provider is removed from the configuration. """ - # Stop the Resonate server + # Stop the Sendspin server await self.server_api.close() for cb in self.unregister_cbs: diff --git a/music_assistant/providers/resonate/timed_client_stream.py b/music_assistant/providers/sendspin/timed_client_stream.py similarity index 98% rename from music_assistant/providers/resonate/timed_client_stream.py rename to music_assistant/providers/sendspin/timed_client_stream.py index c09b9ca8..738c0d82 100644 --- a/music_assistant/providers/resonate/timed_client_stream.py +++ b/music_assistant/providers/sendspin/timed_client_stream.py @@ -2,7 +2,7 @@ Timestamped multi-client audio stream for position-aware playback. This module provides a multi-client streaming implementation optimized for -aioresonate's synchronized multi-room audio playback. Each audio chunk is +aiosendspin's synchronized multi-room audio playback. Each audio chunk is timestamped, allowing late-joining players to start at the correct position for synchronized playback across multiple devices. """ @@ -22,9 +22,9 @@ LOGGER = logging.getLogger(__name__) # Minimum/target buffer retention time in seconds # This 10s buffer is currently required since: -# - aioresonate currently uses a fixed 5s buffer to allow up to ~4s of network interruption +# - aiosendspin currently uses a fixed 5s buffer to allow up to ~4s of network interruption # - ~2s allows for ffmpeg processing time and some margin -# - ~3s are currently needed internally by aioresonate for initial buffering +# - ~3s are currently needed internally by aiosendspin for initial buffering MIN_BUFFER_DURATION = 10.0 # Maximum buffer duration before raising an error (safety mechanism) MAX_BUFFER_DURATION = MIN_BUFFER_DURATION + 5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 87e21411..5cf7a4bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -9,9 +9,9 @@ aiohttp_asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 aiojellyfin==0.14.1 aiomusiccast==0.15.0 -aioresonate==0.13.1 aiortc>=1.6.0 aiorun==2025.1.1 +aiosendspin==1.0.0 aioslimproto==3.1.1 aiosonos==0.1.9 aiosqlite==0.21.0 -- 2.34.1