1919 GroupStateChangedEvent ,
2020 VolumeChangedEvent ,
2121)
22- from aioresonate .server .client import ClientGroupChangedEvent , DisconnectBehaviour
22+ from aioresonate .server .client import DisconnectBehaviour
23+ from aioresonate .server .events import ClientGroupChangedEvent
2324from aioresonate .server .group import (
24- AudioCodec ,
2525 GroupDeletedEvent ,
2626 GroupMemberAddedEvent ,
2727 GroupMemberRemovedEvent ,
28- Metadata ,
2928)
29+ from aioresonate .server .metadata import Metadata
30+ from aioresonate .server .stream import AudioCodec , MediaStream
3031from music_assistant_models .config_entries import ConfigEntry , ConfigValueType
3132from music_assistant_models .constants import PLAYER_CONTROL_NONE
3233from music_assistant_models .enums import (
4344from PIL import Image
4445
4546from music_assistant .constants import CONF_ENTRY_OUTPUT_CODEC , CONF_OUTPUT_CODEC
47+ from music_assistant .helpers .audio import get_player_filter_params
4648from music_assistant .helpers .ffmpeg import get_ffmpeg_stream
4749from music_assistant .models .player import Player , PlayerMedia
4850from music_assistant .providers .universal_group .constants import UGP_PREFIX
4951from music_assistant .providers .universal_group .player import UniversalGroupPlayer
5052
5153if TYPE_CHECKING :
52- from aioresonate .server .client import Client
54+ from aioresonate .server .client import ResonateClient
5355 from music_assistant_models .event import MassEvent
5456
5557 from .provider import ResonateProvider
5860class ResonatePlayer (Player ):
5961 """A resonate audio player in Music Assistant."""
6062
61- api : Client
63+ api : ResonateClient
6264 unsub_event_cb : Callable [[], None ]
6365 unsub_group_event_cb : Callable [[], None ]
6466 last_sent_artwork_url : str | None = None
67+ _playback_task : asyncio .Task [None ] | None = None
6568
6669 def __init__ (self , provider : ResonateProvider , player_id : str ) -> None :
6770 """Initialize the Player."""
@@ -83,8 +86,9 @@ def __init__(self, provider: ResonateProvider, player_id: str) -> None:
8386 self ._attr_can_group_with = {provider .lookup_key }
8487 self ._attr_power_control = PLAYER_CONTROL_NONE
8588 self ._attr_device_info = DeviceInfo ()
86- self ._attr_volume_level = resonate_client .volume
87- self ._attr_volume_muted = resonate_client .muted
89+ if player_client := resonate_client .player :
90+ self ._attr_volume_level = player_client .volume
91+ self ._attr_volume_muted = player_client .muted
8892 self ._attr_available = True
8993 self ._on_unload_callbacks .append (
9094 self .mass .subscribe (
@@ -158,20 +162,24 @@ async def group_event_cb(self, event: GroupEvent) -> None:
158162
159163 async def volume_set (self , volume_level : int ) -> None :
160164 """Handle VOLUME_SET command on the player."""
161- self .api .set_volume (volume_level )
165+ if player_client := self .api .player :
166+ player_client .set_volume (volume_level )
162167
163168 async def volume_mute (self , muted : bool ) -> None :
164169 """Handle VOLUME MUTE command on the player."""
165- if muted :
166- self .api .mute ()
167- else :
168- self .api .unmute ()
170+ if player_client := self .api .player :
171+ if muted :
172+ player_client .mute ()
173+ else :
174+ player_client .unmute ()
169175
170176 async def stop (self ) -> None :
171177 """Stop command."""
172178 self .logger .debug ("Received STOP command on player %s" , self .display_name )
173179 # We don't care if we stopped the stream or it was already stopped
174- self .api .group .stop ()
180+ await self .api .group .stop ()
181+ # Clear the playback task reference (group.stop() handles stopping the stream)
182+ self ._playback_task = None
175183 self ._attr_active_source = None
176184 self ._attr_current_media = None
177185 self .update_state ()
@@ -189,61 +197,93 @@ async def play_media(self, media: PlayerMedia) -> None:
189197 self ._attr_active_source = media .source_id
190198 # playback_state will be set by the group state change event
191199
192- pcm_format = AudioFormat (
193- content_type = ContentType .PCM_S16LE ,
194- sample_rate = 48000 ,
195- bit_depth = 16 ,
196- channels = 2 ,
197- )
200+ # Stop previous stream in case we were already playing something
201+ await self .api .group .stop ()
202+ # Run playback in background task to immediately return
203+ self ._playback_task = asyncio .create_task (self ._run_playback (media ))
204+ self .update_state ()
198205
199- # select audio source
200- if media .media_type == MediaType .PLUGIN_SOURCE :
201- # special case: plugin source stream
202- assert media .custom_data is not None # for type checking
203- audio_source = self .mass .streams .get_plugin_source_stream (
204- plugin_source_id = media .custom_data ["provider" ],
205- output_format = pcm_format ,
206- player_id = self .player_id ,
207- )
208- elif media .source_id and media .source_id .startswith (UGP_PREFIX ):
209- # special case: UGP stream
210- ugp_player = cast ("UniversalGroupPlayer" , self .mass .players .get (media .source_id ))
211- ugp_stream = ugp_player .stream
212- assert ugp_stream is not None # for type checker
213- pcm_format .bit_depth = ugp_stream .base_pcm_format .bit_depth
214- pcm_format .bit_rate = ugp_stream .base_pcm_format .bit_rate
215- pcm_format .channels = ugp_stream .base_pcm_format .channels
216- audio_source = ugp_stream .subscribe_raw ()
217- elif media .source_id and media .queue_item_id :
218- # regular queue (flow) stream request
219- queue = self .mass .player_queues .get (media .source_id )
220- start_queue_item = self .mass .player_queues .get_item (
221- media .source_id , media .queue_item_id
206+ async def _run_playback (self , media : PlayerMedia ) -> None :
207+ """Run the actual playback in a background task."""
208+ try :
209+ pcm_format = AudioFormat (
210+ content_type = ContentType .PCM_S16LE ,
211+ sample_rate = 48000 ,
212+ bit_depth = 16 ,
213+ channels = 2 ,
222214 )
223- assert queue is not None # for type checking
224- assert start_queue_item is not None # for type checking
225- audio_source = self .mass .streams .get_queue_flow_stream (
226- queue = queue , start_queue_item = start_queue_item , pcm_format = pcm_format
227- )
228- else :
229- # assume url or some other direct path
215+
216+ # select audio source
217+ if media .media_type == MediaType .PLUGIN_SOURCE :
218+ # special case: plugin source stream
219+ assert media .custom_data is not None # for type checking
220+ audio_source = self .mass .streams .get_plugin_source_stream (
221+ plugin_source_id = media .custom_data ["provider" ],
222+ output_format = pcm_format ,
223+ player_id = self .player_id ,
224+ )
225+ elif media .source_id and media .source_id .startswith (UGP_PREFIX ):
226+ # special case: UGP stream
227+ ugp_player = cast ("UniversalGroupPlayer" , self .mass .players .get (media .source_id ))
228+ ugp_stream = ugp_player .stream
229+ assert ugp_stream is not None # for type checker
230+ pcm_format .bit_depth = ugp_stream .base_pcm_format .bit_depth
231+ pcm_format .bit_rate = ugp_stream .base_pcm_format .bit_rate
232+ pcm_format .channels = ugp_stream .base_pcm_format .channels
233+ audio_source = ugp_stream .subscribe_raw ()
234+ elif media .source_id and media .queue_item_id :
235+ # regular queue (flow) stream request
236+ queue = self .mass .player_queues .get (media .source_id )
237+ start_queue_item = self .mass .player_queues .get_item (
238+ media .source_id , media .queue_item_id
239+ )
240+ assert queue is not None # for type checking
241+ assert start_queue_item is not None # for type checking
242+ audio_source = self .mass .streams .get_queue_flow_stream (
243+ queue = queue , start_queue_item = start_queue_item , pcm_format = pcm_format
244+ )
245+ else :
246+ # assume url or some other direct path
247+ audio_source = get_ffmpeg_stream (
248+ audio_input = media .uri ,
249+ input_format = AudioFormat (content_type = ContentType .try_parse (media .uri )),
250+ output_format = pcm_format ,
251+ )
252+
253+ output_codec = cast ("str" , self .config .get_value (CONF_OUTPUT_CODEC , "pcm" ))
254+
255+ # Convert string codec to AudioCodec enum
256+ audio_codec = AudioCodec (output_codec )
257+
258+ # Apply DSP and other audio filters
230259 audio_source = get_ffmpeg_stream (
231- audio_input = media . uri ,
232- input_format = AudioFormat ( content_type = ContentType . try_parse ( media . uri )) ,
260+ audio_input = audio_source ,
261+ input_format = pcm_format ,
233262 output_format = pcm_format ,
263+ filter_params = get_player_filter_params (
264+ self .mass , self .player_id , pcm_format , pcm_format
265+ ),
234266 )
235267
236- output_codec = cast ("str" , self .config .get_value (CONF_OUTPUT_CODEC , "pcm" ))
237-
238- # Convert string codec to AudioCodec enum
239- audio_codec = AudioCodec (output_codec )
268+ # Create MediaStream wrapping the audio source generator
269+ media_stream = MediaStream (
270+ source = audio_source ,
271+ audio_format = ResonateAudioFormat (
272+ sample_rate = pcm_format .sample_rate ,
273+ bit_depth = pcm_format .bit_depth ,
274+ channels = pcm_format .channels ,
275+ codec = audio_codec ,
276+ ),
277+ )
240278
241- await self .api .group .play_media (
242- audio_source ,
243- ResonateAudioFormat (pcm_format .sample_rate , pcm_format .bit_depth , pcm_format .channels ),
244- preferred_stream_codec = audio_codec ,
245- )
246- self .update_state ()
279+ stop_time = await self .api .group .play_media (media_stream )
280+ await self .api .group .stop (stop_time )
281+ except asyncio .CancelledError :
282+ self .logger .debug ("Playback cancelled for player %s" , self .display_name )
283+ raise
284+ except Exception :
285+ self .logger .exception ("Error during playback for player %s" , self .display_name )
286+ raise
247287
248288 async def set_members (
249289 self ,
@@ -257,14 +297,14 @@ async def set_members(
257297 for player_id in player_ids_to_remove or []:
258298 player = self .mass .players .get (player_id , True )
259299 player = cast ("ResonatePlayer" , player ) # For type checking
260- self .api .group .remove_client (player .api )
300+ await self .api .group .remove_client (player .api )
261301 player .api .disconnect_behaviour = DisconnectBehaviour .STOP
262302 self ._attr_group_members .remove (player_id )
263303 for player_id in player_ids_to_add or []:
264304 player = self .mass .players .get (player_id , True )
265305 player = cast ("ResonatePlayer" , player ) # For type checking
266306 player .api .disconnect_behaviour = DisconnectBehaviour .UNGROUP
267- self .api .group .add_client (player .api )
307+ await self .api .group .add_client (player .api )
268308 self ._attr_group_members .append (player_id )
269309 self .update_state ()
270310
@@ -303,7 +343,7 @@ async def _on_queue_update(self, event: MassEvent) -> None:
303343 artist = artist_str
304344 if _album := getattr (media_item , "album" , None ):
305345 album = _album .name
306- year = _album . year
346+ year = getattr ( _album , " year" , None )
307347 album_artist = getattr (_album , "artist_str" , None )
308348 if _track_number := getattr (media_item , "track_number" , None ):
309349 track = _track_number
0 commit comments