Fix queue track repeat + Some improvements to Sonos players (#1029)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 25 Jan 2024 23:54:27 +0000 (00:54 +0100)
committerGitHub <noreply@github.com>
Thu, 25 Jan 2024 23:54:27 +0000 (00:54 +0100)
music_assistant/common/models/player_queue.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/players.py
music_assistant/server/controllers/streams.py
music_assistant/server/models/player_provider.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/sonos/__init__.py

index c08b0a5e6606e1d4a8ea5bffa984c14445d3b282..eca837227484056a4c0d6f3a97c36ba90bcd73c4 100644 (file)
@@ -38,7 +38,6 @@ class PlayerQueue(DataClassDictMixin):
     flow_mode: bool = False
     # flow_mode_start_index: index of the first item of the flow stream
     flow_mode_start_index: int = 0
-    next_track_enqueued: bool = False
 
     @property
     def corrected_elapsed_time(self) -> float:
index 10f720bfcd41b6e392ab37f43029961a56351b99..e0884f299497cf21596f0d1c242c990c8543d22b 100755 (executable)
@@ -671,7 +671,7 @@ class PlayerQueuesController(CoreController):
         if not queue:
             raise PlayerUnavailableError(f"PlayerQueue {queue_id} is not available")
         if current_item_id_or_index is None:
-            cur_index = queue.index_in_buffer or queue.current_index or 0
+            cur_index = queue.index_in_buffer
         elif isinstance(current_item_id_or_index, str):
             cur_index = self.index_by_id(queue_id, current_item_id_or_index)
         else:
@@ -696,7 +696,6 @@ class PlayerQueuesController(CoreController):
                 idx += 1
         if next_item is None:
             raise QueueEmpty("No more (playable) tracks left in the queue.")
-        queue.next_track_enqueued = True
         return next_item
 
     # Main queue manipulation methods
@@ -796,15 +795,13 @@ class PlayerQueuesController(CoreController):
         if not queue_items or cur_index is None:
             # queue is empty
             return None
-        if cur_index is None:
-            cur_index = queue.current_index
         # handle repeat single track
         if queue.repeat_mode == RepeatMode.ONE and not is_skip:
             return cur_index
         # handle cur_index is last index of the queue
         if cur_index >= (len(queue_items) - 1):
             # if repeat all is enabled, we simply start again from the beginning
-            return 0 if RepeatMode.ALL else None
+            return 0 if queue.repeat_mode == RepeatMode.ALL else None
         return cur_index + 1
 
     def _get_next_item(self, queue_id: str, cur_index: int | None = None) -> QueueItem | None:
@@ -845,33 +842,41 @@ class PlayerQueuesController(CoreController):
             duration = current_item.streamdetails.seconds_streamed
         else:
             duration = current_item.duration
-        seconds_remaining = duration - player.corrected_elapsed_time
+        seconds_remaining = int(duration - player.corrected_elapsed_time)
+
+        async def _enqueue_next(index: int, supports_enqueue: bool = False):
+            with suppress(QueueEmpty):
+                next_item = await self.preload_next_item(queue.queue_id, index)
+                if supports_enqueue:
+                    await self.mass.players.enqueue_next_queue_item(
+                        player_id=player.player_id, queue_item=next_item
+                    )
+                    return
+                await self.play_index(queue.queue_id, next_item.queue_item_id)
 
         if PlayerFeature.ENQUEUE_NEXT in player.supported_features:
             # player supports enqueue next feature.
-            # we enqueue the next track 15 seconds before the current track ends
-            end_of_track_reached = seconds_remaining <= 15
-        else:
-            # player does not support enqueue next feature.
-            # we wait for the player to stop after it reaches the end of the track
-            prev_seconds_remaining = prev_state.get("seconds_remaining", seconds_remaining)
-            end_of_track_reached = prev_seconds_remaining <= 6 and queue.state == PlayerState.IDLE
-            new_state["seconds_remaining"] = seconds_remaining
-
-        if not end_of_track_reached:
-            queue.next_track_enqueued = False  # reset
+            # we enqueue the next track after a new track
+            # has started playing and before the current track ends
+            new_track_started = new_state.get("state") == PlayerState.PLAYING and prev_state.get(
+                "current_index"
+            ) != new_state.get("current_index")
+            if (
+                new_track_started
+                or seconds_remaining == 15
+                or int(player.corrected_elapsed_time) == 1
+            ):
+                self.mass.create_task(_enqueue_next(queue.current_index, True))
             return
-        if queue.next_track_enqueued:
-            return  # already enqueued
 
-        async def _enqueue_next(index: int):
-            with suppress(QueueEmpty):
-                next_item = await self.preload_next_item(queue.queue_id, index)
-                await self.mass.players.enqueue_next_queue_item(
-                    player_id=player.player_id, queue_item=next_item
-                )
+        # player does not support enqueue next feature.
+        # we wait for the player to stop after it reaches the end of the track
+        prev_seconds_remaining = prev_state.get("seconds_remaining", seconds_remaining)
+        if prev_seconds_remaining <= 6 and queue.state == PlayerState.IDLE:
+            self.mass.create_task(_enqueue_next(queue.current_index, False))
+            return
 
-        self.mass.create_task(_enqueue_next(queue.current_index))
+        new_state["seconds_remaining"] = seconds_remaining
 
     async def _get_radio_tracks(self, queue_id: str) -> list[MediaItemType]:
         """Call the registered music providers for dynamic tracks."""
index cced5ca3da44c9664d642ccdc1c1cb3c9f19f4fd..607cf6b4f8d036e23e3c99b1dc4ae25f47527e74 100755 (executable)
@@ -608,10 +608,9 @@ class PlayerController(CoreController):
         """
         Handle enqueuing of the next queue item on the player.
 
-        If the player supports PlayerFeature.ENQUE_NEXT:
-          This will be called about 10 seconds before the end of the track.
-        If the player does NOT report support for PlayerFeature.ENQUE_NEXT:
-          This will be called when the end of the track is reached.
+        Only called if the player supports PlayerFeature.ENQUE_NEXT.
+        Called about 1 second after a new track started playing.
+        Called about 15 seconds before the end of the current track.
 
         A PlayerProvider implementation is in itself responsible for handling this
         so that the queue items keep playing until its empty or the player stopped.
@@ -944,7 +943,6 @@ class PlayerController(CoreController):
         # extract player features from first/random player
         for member in members:
             if first_player := self.get(member):
-                supported_features = first_player.supported_features
                 break
         else:
             # edge case: no child player is (yet) available; postpone register
@@ -957,7 +955,7 @@ class PlayerController(CoreController):
             available=True,
             powered=False,
             device_info=DeviceInfo(model="SyncGroup", manufacturer=provider.title()),
-            supported_features=supported_features,
+            supported_features=first_player.supported_features,
             group_childs=set(members),
         )
         self.mass.players.register_or_update(player)
index 67ddd7ce8aeb473a8a26284ed75c9868cf0ab050..f75b6c8279f1ff649b6ebd80c38eefcd5b05b887 100644 (file)
@@ -397,27 +397,7 @@ class StreamsController(CoreController):
         fmt = output_codec.value
         # handle raw pcm
         if output_codec.is_pcm():
-            player = self.mass.players.get(queue_item.queue_id)
-            player_max_bit_depth = 24 if player.supports_24bit else 16
-            if flow_mode:
-                output_sample_rate = min(FLOW_MAX_SAMPLE_RATE, player.max_sample_rate)
-                output_bit_depth = min(FLOW_MAX_BIT_DEPTH, player_max_bit_depth)
-            else:
-                await set_stream_details(self.mass, queue_item)
-                output_sample_rate = min(
-                    queue_item.streamdetails.audio_format.sample_rate, player.max_sample_rate
-                )
-                output_bit_depth = min(
-                    queue_item.streamdetails.audio_format.bit_depth, player_max_bit_depth
-                )
-            output_channels = self.mass.config.get_raw_player_config_value(
-                queue_item.queue_id, CONF_OUTPUT_CHANNELS, "stereo"
-            )
-            channels = 1 if output_channels != "stereo" else 2
-            fmt += (
-                f";codec=pcm;rate={output_sample_rate};"
-                f"bitrate={output_bit_depth};channels={channels}"
-            )
+            raise RuntimeError("PCM is not possible as output format")
         query_params = {}
         base_path = "flow" if flow_mode else "single"
         url = f"{self._server.base_url}/{queue_item.queue_id}/{base_path}/{queue_item.queue_item_id}.{fmt}"  # noqa: E501
@@ -515,7 +495,7 @@ class StreamsController(CoreController):
         self.logger.debug(
             "Start serving audio stream for QueueItem %s to %s", queue_item.uri, queue.display_name
         )
-        queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_item_id)
+        queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_id, queue_item_id)
         # collect player specific ffmpeg args to re-encode the source PCM stream
         pcm_format = AudioFormat(
             content_type=ContentType.from_bit_depth(
@@ -821,7 +801,9 @@ class StreamsController(CoreController):
                 queue_track.name,
                 queue.display_name,
             )
-            queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_track.queue_item_id)
+            queue.index_in_buffer = self.mass.player_queues.index_by_id(
+                queue.queue_id, queue_track.queue_item_id
+            )
 
             # set some basic vars
             pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
index 85f03c5397b5718d7ad33d61dea1cc50eaa10b72..0d06a2afb9649df5ddfc8dd3f70283fdeae946bb 100644 (file)
@@ -132,10 +132,9 @@ class PlayerProvider(Provider):
         """
         Handle enqueuing of the next queue item on the player.
 
-        If the player supports PlayerFeature.ENQUE_NEXT:
-          This will be called about 10 seconds before the end of the track.
-        If the player does NOT report support for PlayerFeature.ENQUE_NEXT:
-          This will be called when the end of the track is reached.
+        Only called if the player supports PlayerFeature.ENQUE_NEXT.
+        Called about 1 second after a new track started playing.
+        Called about 15 seconds before the end of the current track.
 
         A PlayerProvider implementation is in itself responsible for handling this
         so that the queue items keep playing until its empty or the player stopped.
@@ -143,12 +142,6 @@ class PlayerProvider(Provider):
         This will NOT be called if the end of the queue is reached (and repeat disabled).
         This will NOT be called if the player is using flow mode to playback the queue.
         """
-        # default implementation (for a player without enqueue_next feature):
-        # simply start playback of the next track.
-        # player providers need to override this behavior if/when needed
-        await self.play_media(
-            player_id=player_id, queue_item=queue_item, seek_position=0, fade_in=False
-        )
 
     async def cmd_power(self, player_id: str, powered: bool) -> None:
         """Send POWER command to given player.
index 013767a09d9ccba4b21fd4cd7ff97fcf2156077a..2fd3273f7d139fe7259e6cb9c8d7652b2874b236 100644 (file)
@@ -250,19 +250,17 @@ class ChromecastProvider(PlayerProvider):
             # This comes at the cost of metadata (cast does not support ICY metadata).
             cc_queue_items = [
                 self._create_cc_queue_item(None, url),
+                # add a special 'command' item to the queue
+                # this allows for on-player next buttons/commands to still work
+                self._create_cc_queue_item(
+                    None, self.mass.streams.get_command_url(queue_item.queue_id, "next")
+                ),
             ]
         else:
             # handle normal playback using the chromecast queue to play items one by one
             cc_queue_items = [
                 self._create_cc_queue_item(queue_item, url),
             ]
-        # add a special 'command' item to the queue
-        # this allows for on-player next buttons/commands to still work
-        cc_queue_items.append(
-            self._create_cc_queue_item(
-                None, self.mass.streams.get_command_url(queue_item.queue_id, "next")
-            )
-        )
         queuedata = {
             "type": "QUEUE_LOAD",
             "repeatMode": "REPEAT_OFF",  # handled by our queue controller
@@ -299,8 +297,16 @@ class ChromecastProvider(PlayerProvider):
             queue_item=queue_item,
             output_codec=ContentType.FLAC,
         )
-        if cast_queue_items := getattr(castplayer.cc.media_controller.status, "items"):
+        next_item_id = None
+        if (cast_queue_items := getattr(castplayer.cc.media_controller.status, "items")) and len(
+            cast_queue_items
+        ) > 1:
             next_item_id = cast_queue_items[-1]["itemId"]
+            if (
+                cast_queue_items[-1].get("media", {}).get("customData", {}).get("queue_item_id")
+                == queue_item.queue_item_id
+            ):
+                return
         queuedata = {
             "type": "QUEUE_INSERT",
             "insertBefore": next_item_id,
@@ -308,7 +314,12 @@ class ChromecastProvider(PlayerProvider):
         }
         media_controller = castplayer.cc.media_controller
         queuedata["mediaSessionId"] = media_controller.status.media_session_id
-        await asyncio.to_thread(media_controller.send_message, queuedata, True)
+        self.mass.create_task(media_controller.send_message, queuedata, inc_session_id=True)
+        self.logger.info(
+            "Enqued next track (%s) to player %s",
+            queue_item.name if queue_item else url,
+            castplayer.player.display_name,
+        )
 
     async def poll_player(self, player_id: str) -> None:
         """Poll player for state updates.
index e9d0845a4c7c90fc11614064e413fbb32ab7d51b..f973dc87e82f75dadba32e3afe19eb1d443a37b9 100644 (file)
@@ -410,20 +410,7 @@ class DLNAPlayerProvider(PlayerProvider):
 
     @catch_request_errors
     async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem):
-        """
-        Handle enqueuing of the next queue item on the player.
-
-        If the player supports PlayerFeature.ENQUE_NEXT:
-          This will be called about 10 seconds before the end of the track.
-        If the player does NOT report support for PlayerFeature.ENQUE_NEXT:
-          This will be called when the end of the track is reached.
-
-        A PlayerProvider implementation is in itself responsible for handling this
-        so that the queue items keep playing until its empty or the player stopped.
-
-        This will NOT be called if the end of the queue is reached (and repeat disabled).
-        This will NOT be called if flow mode is enabled on the queue.
-        """
+        """Handle enqueuing of the next queue item on the player."""
         dlna_player = self.dlnaplayers[player_id]
         url = await self.mass.streams.resolve_stream_url(
             queue_item=queue_item,
@@ -431,25 +418,12 @@ class DLNAPlayerProvider(PlayerProvider):
         )
         didl_metadata = create_didl_metadata(self.mass, url, queue_item)
         title = queue_item.name
-        if self.mass.config.get_raw_player_config_value(player_id, CONF_ENQUEUE_NEXT, False):
-            # use the 'next_transport_uri' feature
-            await dlna_player.device.async_set_next_transport_uri(url, title, didl_metadata)
-            self.logger.debug(
-                "Enqued next track (%s) to player %s",
-                title,
-                dlna_player.player.display_name,
-            )
-        else:
-            # simply use regular play command
-            await dlna_player.device.async_set_transport_uri(url, title, didl_metadata)
-            # Play it
-            await dlna_player.device.async_wait_for_can_play(10)
-            # optimistically set this timestamp to help in case of a player
-            # that does not report the progress
-            now = time.time()
-            dlna_player.player.elapsed_time = 0
-            dlna_player.player.elapsed_time_last_updated = now
-            await dlna_player.device.async_play()
+        await dlna_player.device.async_set_next_transport_uri(url, title, didl_metadata)
+        self.logger.info(
+            "Enqued next track (%s) to player %s",
+            title,
+            dlna_player.player.display_name,
+        )
 
     @catch_request_errors
     async def cmd_pause(self, player_id: str) -> None:
index 1b9e3fd4774587f2cca9f10c13104a14c5a6077a..9248f0db99eed9688f08ada374cae3ad53070671 100644 (file)
@@ -5,7 +5,6 @@ import asyncio
 import logging
 import time
 from contextlib import suppress
-from dataclasses import dataclass, field
 from typing import TYPE_CHECKING, Any
 
 import soco
@@ -41,6 +40,7 @@ if TYPE_CHECKING:
     from music_assistant.server.controllers.streams import MultiClientStreamJob
     from music_assistant.server.models import ProviderInstanceType
 
+LOGGER = logging.getLogger(__name__)
 
 PLAYER_FEATURES = (
     PlayerFeature.SYNC,
@@ -66,6 +66,8 @@ HIRES_MODELS = (
     "Sonos Amp",
     "SYMFONISK Bookshelf",
     "SYMFONISK Table Lamp",
+    "Sonos Era 100",
+    "Sonos Era 300",
 )
 
 
@@ -104,31 +106,29 @@ async def get_config_entries(
     )
 
 
-@dataclass
 class SonosPlayer:
     """Wrapper around Sonos/SoCo with some additional attributes."""
 
-    player_id: str
-    soco: soco.SoCo
-    player: Player
-    is_stereo_pair: bool = False
-    elapsed_time: int = 0
-    playback_started: float | None = None
-    need_elapsed_time_workaround: bool = False
-
-    subscriptions: list[SubscriptionBase] = field(default_factory=list)
-
-    transport_info: dict = field(default_factory=dict)
-    track_info: dict = field(default_factory=dict)
-    speaker_info: dict = field(default_factory=dict)
-    rendering_control_info: dict = field(default_factory=dict)
-    group_info: ZoneGroup | None = None
-
-    speaker_info_updated: float = 0.0
-    transport_info_updated: float = 0.0
-    track_info_updated: float = 0.0
-    rendering_control_info_updated: float = 0.0
-    group_info_updated: float = 0.0
+    def __init__(self, sonos_prov: SonosPlayerProvider, soco_device: soco.SoCo) -> None:
+        """Initialize SonosPlayer instance."""
+        self.sonos_prov = sonos_prov
+        self.player_id = soco_device.uid
+        self.soco_device = soco_device
+        self.is_stereo_pair: bool = False
+        self.elapsed_time: int = 0
+        self.playback_started: float | None = None
+        self.need_elapsed_time_workaround: bool = False
+        self.subscriptions: list[SubscriptionBase] = []
+        self.transport_info: dict = {}
+        self.track_info: dict = {}
+        self.speaker_info: dict = {}
+        self.rendering_control_info: dict = {}
+        self.group_info: ZoneGroup | None = None
+        self.speaker_info_updated: float = 0.0
+        self.transport_info_updated: float = 0.0
+        self.track_info_updated: float = 0.0
+        self.rendering_control_info_updated: float = 0.0
+        self.group_info_updated: float = 0.0
 
     def update_info(
         self,
@@ -141,13 +141,13 @@ class SonosPlayer:
         """Poll all info from player (must be run in executor thread)."""
         # transport info
         if update_transport_info:
-            transport_info = self.soco.get_current_transport_info()
+            transport_info = self.soco_device.get_current_transport_info()
             if transport_info.get("current_transport_state") != "TRANSITIONING":
                 self.transport_info = transport_info
                 self.transport_info_updated = time.time()
         # track info
         if update_track_info:
-            self.track_info = self.soco.get_current_track_info()
+            self.track_info = self.soco_device.get_current_track_info()
             # sonos reports bullshit elapsed time while playing radio (or flow mode),
             # trying to be "smart" and resetting the counter when new ICY metadata is detected
             # we try to detect this and work around it
@@ -158,30 +158,33 @@ class SonosPlayer:
 
         # speaker info
         if update_speaker_info:
-            self.speaker_info = self.soco.get_speaker_info()
+            self.speaker_info = self.soco_device.get_speaker_info()
             self.speaker_info_updated = time.time()
         # rendering control info
         if update_rendering_control_info:
-            self.rendering_control_info["volume"] = self.soco.volume
-            self.rendering_control_info["mute"] = self.soco.mute
+            self.rendering_control_info["volume"] = self.soco_device.volume
+            self.rendering_control_info["mute"] = self.soco_device.mute
             self.rendering_control_info_updated = time.time()
         # group info
         if update_group_info:
-            self.group_info = self.soco.group
+            self.group_info = self.soco_device.group
             self.group_info_updated = time.time()
 
     def update_attributes(self):
         """Update attributes of the MA Player from soco.SoCo state."""
+        mass_player = self.sonos_prov.mass.players.get(self.player_id)
+        if not mass_player:
+            return
         now = time.time()
         # generic attributes (speaker_info)
-        self.player.available = True
-        self.player.name = self.speaker_info["zone_name"]
-        self.player.volume_level = int(self.rendering_control_info["volume"])
-        self.player.volume_muted = self.rendering_control_info["mute"]
+        mass_player.available = True
+        mass_player.name = self.speaker_info["zone_name"]
+        mass_player.volume_level = int(self.rendering_control_info["volume"])
+        mass_player.volume_muted = self.rendering_control_info["mute"]
 
         # transport info (playback state)
         current_transport_state = self.transport_info["current_transport_state"]
-        self.player.state = current_state = _convert_state(current_transport_state)
+        mass_player.state = current_state = _convert_state(current_transport_state)
 
         if self.playback_started is not None and current_state == PlayerState.IDLE:
             self.playback_started = None
@@ -189,19 +192,18 @@ class SonosPlayer:
             self.playback_started = now
 
         # media info (track info)
-        self.player.current_item_id = self.track_info["uri"]
-        if self.player.player_id in self.player.current_item_id:
-            self.player.active_source = self.player.player_id
-        elif "spotify" in self.player.current_item_id:
-            self.player.active_source = "spotify"
-        elif self.player.current_item_id.startswith("http"):
-            self.player.active_source = "http"
+        mass_player.current_item_id = self.track_info["uri"]
+        if mass_player.player_id in mass_player.current_item_id:
+            mass_player.active_source = mass_player.player_id
+        elif "spotify" in mass_player.current_item_id:
+            mass_player.active_source = "spotify"
         else:
-            # TODO: handle other possible sources here
-            self.player.active_source = None
+            mass_player.active_source = self.soco_device.music_source_from_uri(
+                self.track_info["uri"]
+            )
         if not self.need_elapsed_time_workaround:
-            self.player.elapsed_time = self.elapsed_time
-            self.player.elapsed_time_last_updated = self.track_info_updated
+            mass_player.elapsed_time = self.elapsed_time
+            mass_player.elapsed_time_last_updated = self.track_info_updated
 
         # zone topology (syncing/grouping) details
         if (
@@ -210,22 +212,22 @@ class SonosPlayer:
             and self.group_info.coordinator.uid == self.player_id
         ):
             # this player is the sync leader
-            self.player.synced_to = None
+            mass_player.synced_to = None
             group_members = {x.uid for x in self.group_info.members if x.is_visible}
             if not group_members:
                 # not sure about this ?!
-                self.player.type = PlayerType.PLAYER
+                mass_player.type = PlayerType.PLAYER
             elif group_members == {self.player_id}:
-                self.player.group_childs = set()
+                mass_player.group_childs = set()
             else:
-                self.player.group_childs = group_members
+                mass_player.group_childs = group_members
         elif self.group_info and self.group_info.coordinator:
             # player is synced to
-            self.player.group_childs = set()
-            self.player.synced_to = self.group_info.coordinator.uid
+            mass_player.group_childs = set()
+            mass_player.synced_to = self.group_info.coordinator.uid
         else:
             # unsure
-            self.player.group_childs = set()
+            mass_player.group_childs = set()
 
     async def check_poll(self) -> None:
         """Check if any of the endpoints needs to be polled for info."""
@@ -256,6 +258,85 @@ class SonosPlayer:
             update_group_info,
         )
 
+    async def connect(self) -> None:
+        """Handle (re)connect of the Sonos player."""
+        # poll all endpoints once and update attributes
+        self.speaker_info = await asyncio.to_thread(self.soco_device.get_speaker_info, True)
+        self.speaker_info_updated = time.time()
+        await self.check_poll()
+        self.update_attributes()
+
+        # handle subscriptions to events
+        def subscribe(service, _callback):
+            queue = ProcessSonosEventQueue(_callback)
+            sub = service.subscribe(auto_renew=True, event_queue=queue)
+            self.subscriptions.append(sub)
+
+        subscribe(self.soco_device.avTransport, self._handle_av_transport_event)
+        subscribe(self.soco_device.renderingControl, self._handle_rendering_control_event)
+        subscribe(self.soco_device.zoneGroupTopology, self._handle_zone_group_topology_event)
+
+    def disconnect(self) -> None:
+        """Handle disconnect."""
+        mass_player = self.sonos_prov.mass.players.get(self.player_id)
+        mass_player.available = False
+        LOGGER.debug("Unsubscribing from events for %s", mass_player.display_name)
+        for subscription in self.subscriptions:
+            subscription.unsubscribe()
+        self.subscriptions = []
+
+    async def reconnect(self, soco_device: soco.SoCo) -> None:
+        """Handle reconnect."""
+        if self.subscriptions:
+            self.disconnect()
+        self.soco_device = soco_device
+        await self.connect()
+
+    def _handle_av_transport_event(self, event: SonosEvent):
+        """Handle a soco.SoCo AVTransport event."""
+        LOGGER.debug("Received AVTransport event for Player %s", self.soco_device.player_name)
+
+        if "transport_state" in event.variables:
+            new_state = event.variables["transport_state"]
+            if new_state == "TRANSITIONING":
+                return
+            self.transport_info["current_transport_state"] = new_state
+
+        if "current_track_uri" in event.variables:
+            self.transport_info["uri"] = event.variables["current_track_uri"]
+
+        self.transport_info_updated = time.time()
+        asyncio.run_coroutine_threadsafe(
+            self.sonos_prov.update_player(self), self.sonos_prov.mass.loop
+        )
+
+    def _handle_rendering_control_event(self, event: SonosEvent):
+        """Handle a soco.SoCo RenderingControl event."""
+        LOGGER.debug(
+            "Received RenderingControl event for Player %s",
+            self.soco_device.player_name,
+        )
+        if "volume" in event.variables:
+            self.rendering_control_info["volume"] = event.variables["volume"]["Master"]
+        if "mute" in event.variables:
+            self.rendering_control_info["mute"] = event.variables["mute"]["Master"] == "1"
+        self.rendering_control_info_updated = time.time()
+        asyncio.run_coroutine_threadsafe(
+            self.sonos_prov.update_player(self), self.sonos_prov.mass.loop
+        )
+
+    def _handle_zone_group_topology_event(self, event: SonosEvent):  # noqa: ARG002
+        """Handle a soco.SoCo ZoneGroupTopology event."""
+        LOGGER.debug(
+            "Received ZoneGroupTopology event for Player %s",
+            self.soco_device.player_name,
+        )
+        self.group_info = self.soco_device.group
+        self.group_info_updated = time.time()
+        asyncio.run_coroutine_threadsafe(
+            self.sonos_prov.update_player(self), self.sonos_prov.mass.loop
+        )
+
 
 class SonosPlayerProvider(PlayerProvider):
     """Sonos Player provider."""
@@ -290,12 +371,7 @@ class SonosPlayerProvider(PlayerProvider):
         if self.sonosplayers:
             for player_id in list(self.sonosplayers):
                 player = self.sonosplayers.pop(player_id)
-                player.player.available = False
-                if player.soco.is_coordinator:
-                    try:
-                        player.soco.end_direct_control_session()
-                    except Exception as err:
-                        self.logger.exception(err)
+                player.disconnect()
         self.sonosplayers = None
 
     async def get_player_config_entries(
@@ -314,7 +390,7 @@ class SonosPlayerProvider(PlayerProvider):
                 default_value=0,
                 range=(-10, 10),
                 description="Set the Bass level for the Sonos player",
-                value=sonos_player.soco.bass,
+                value=sonos_player.soco_device.bass,
                 advanced=True,
             ),
             ConfigEntry(
@@ -324,7 +400,7 @@ class SonosPlayerProvider(PlayerProvider):
                 default_value=0,
                 range=(-10, 10),
                 description="Set the Treble level for the Sonos player",
-                value=sonos_player.soco.treble,
+                value=sonos_player.soco_device.treble,
                 advanced=True,
             ),
             ConfigEntry(
@@ -333,7 +409,7 @@ class SonosPlayerProvider(PlayerProvider):
                 label="Loudness compensation",
                 default_value=True,
                 description="Enable loudness compensation on the Sonos player",
-                value=sonos_player.soco.loudness,
+                value=sonos_player.soco_device.loudness,
                 advanced=True,
             ),
         )
@@ -350,18 +426,18 @@ class SonosPlayerProvider(PlayerProvider):
             return
         if "values/sonos_bass" in changed_keys:
             self.mass.create_task(
-                sonos_player.soco.renderingControl.SetBass,
+                sonos_player.soco_device.renderingControl.SetBass,
                 [("InstanceID", 0), ("DesiredBass", config.get_value("sonos_bass"))],
             )
         if "values/sonos_treble" in changed_keys:
             self.mass.create_task(
-                sonos_player.soco.renderingControl.SetTreble,
+                sonos_player.soco_device.renderingControl.SetTreble,
                 [("InstanceID", 0), ("DesiredTreble", config.get_value("sonos_treble"))],
             )
         if "values/sonos_loudness" in changed_keys:
             loudness_value = "1" if config.get_value("sonos_loudness") else "0"
             self.mass.create_task(
-                sonos_player.soco.renderingControl.SetLoudness,
+                sonos_player.soco_device.renderingControl.SetLoudness,
                 [
                     ("InstanceID", 0),
                     ("Channel", "Master"),
@@ -372,31 +448,31 @@ class SonosPlayerProvider(PlayerProvider):
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player."""
         sonos_player = self.sonosplayers[player_id]
-        if not sonos_player.soco.is_coordinator:
+        if not sonos_player.soco_device.is_coordinator:
             self.logger.debug(
                 "Ignore STOP command for %s: Player is synced to another player.",
                 player_id,
             )
             return
-        await asyncio.to_thread(sonos_player.soco.stop)
-        await asyncio.to_thread(sonos_player.soco.clear_queue)
+        await asyncio.to_thread(sonos_player.soco_device.stop)
+        await asyncio.to_thread(sonos_player.soco_device.clear_queue)
         sonos_player.playback_started = None
 
     async def cmd_play(self, player_id: str) -> None:
         """Send PLAY command to given player."""
         sonos_player = self.sonosplayers[player_id]
-        if not sonos_player.soco.is_coordinator:
+        if not sonos_player.soco_device.is_coordinator:
             self.logger.debug(
                 "Ignore PLAY command for %s: Player is synced to another player.",
                 player_id,
             )
             return
-        await asyncio.to_thread(sonos_player.soco.play)
+        await asyncio.to_thread(sonos_player.soco_device.play)
 
     async def cmd_pause(self, player_id: str) -> None:
         """Send PAUSE command to given player."""
         sonos_player = self.sonosplayers[player_id]
-        if not sonos_player.soco.is_coordinator:
+        if not sonos_player.soco_device.is_coordinator:
             self.logger.debug(
                 "Ignore PLAY command for %s: Player is synced to another player.",
                 player_id,
@@ -406,14 +482,14 @@ class SonosPlayerProvider(PlayerProvider):
             # no pause allowed when radio/flow mode is active
             await self.cmd_stop(player_id)
             return
-        await asyncio.to_thread(sonos_player.soco.pause)
+        await asyncio.to_thread(sonos_player.soco_device.pause)
 
     async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
         """Send VOLUME_SET command to given player."""
 
         def set_volume_level(player_id: str, volume_level: int) -> None:
             sonos_player = self.sonosplayers[player_id]
-            sonos_player.soco.volume = volume_level
+            sonos_player.soco_device.volume = volume_level
 
         await asyncio.to_thread(set_volume_level, player_id, volume_level)
 
@@ -422,7 +498,7 @@ class SonosPlayerProvider(PlayerProvider):
 
         def set_volume_mute(player_id: str, muted: bool) -> None:
             sonos_player = self.sonosplayers[player_id]
-            sonos_player.soco.mute = muted
+            sonos_player.soco_device.mute = muted
 
         await asyncio.to_thread(set_volume_mute, player_id, muted)
 
@@ -439,7 +515,7 @@ class SonosPlayerProvider(PlayerProvider):
         while True:
             try:
                 await asyncio.to_thread(
-                    sonos_player.soco.join, self.sonosplayers[target_player].soco
+                    sonos_player.soco_device.join, self.sonosplayers[target_player].soco
                 )
                 break
             except soco.exceptions.SoCoUPnPException as err:
@@ -460,7 +536,7 @@ class SonosPlayerProvider(PlayerProvider):
             - player_id: player_id of the player to handle the command.
         """
         sonos_player = self.sonosplayers[player_id]
-        await asyncio.to_thread(sonos_player.soco.unjoin)
+        await asyncio.to_thread(sonos_player.soco_device.unjoin)
         await asyncio.to_thread(
             sonos_player.update_info,
             update_group_info=True,
@@ -491,22 +567,20 @@ class SonosPlayerProvider(PlayerProvider):
             flow_mode=False,
         )
         sonos_player = self.sonosplayers[player_id]
-        if not sonos_player.soco.is_coordinator:
+        mass_player = self.mass.players.get(player_id)
+        if not sonos_player.soco_device.is_coordinator:
             # this should be already handled by the player manager, but just in case...
             raise PlayerCommandFailed(
-                f"Player {sonos_player.player.display_name} can not "
+                f"Player {mass_player.display_name} can not "
                 "accept play_media command, it is synced to another player."
             )
-        # always stop and clear queue first
-        await asyncio.to_thread(sonos_player.soco.stop)
-        await asyncio.to_thread(sonos_player.soco.clear_queue)
-        await self._enqueue_item(sonos_player, url=url, queue_item=queue_item)
-        await asyncio.to_thread(sonos_player.soco.play_from_queue, 0)
+        metadata = create_didl_metadata(self.mass, url, queue_item)
+        await asyncio.to_thread(sonos_player.soco_device.play_uri, url, meta=metadata)
         # optimistically set this timestamp to help figure out elapsed time later
         now = time.time()
         sonos_player.playback_started = now
-        sonos_player.player.elapsed_time = 0
-        sonos_player.player.elapsed_time_last_updated = now
+        mass_player.elapsed_time = 0
+        mass_player.elapsed_time_last_updated = now
 
     async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> None:
         """Handle PLAY STREAM on given player.
@@ -515,21 +589,25 @@ class SonosPlayerProvider(PlayerProvider):
         """
         url = stream_job.resolve_stream_url(player_id, ContentType.MP3)
         sonos_player = self.sonosplayers[player_id]
-        if not sonos_player.soco.is_coordinator:
+        mass_player = self.mass.players.get(player_id)
+        if not sonos_player.soco_device.is_coordinator:
             # this should be already handled by the player manager, but just in case...
             raise PlayerCommandFailed(
-                f"Player {sonos_player.player.display_name} can not "
+                f"Player {mass_player.display_name} can not "
                 "accept play_stream command, it is synced to another player."
             )
-        # always stop and clear queue first
-        await asyncio.to_thread(sonos_player.soco.stop)
-        await asyncio.to_thread(sonos_player.soco.clear_queue)
-        await asyncio.to_thread(sonos_player.soco.play_uri, url, force_radio=True)
+        metadata = create_didl_metadata(self.mass, url, None)
+        await asyncio.to_thread(sonos_player.soco_device.play_uri, url, meta=metadata)
+        # add a special 'command' item to the sonos queue
+        # this allows for on-player next buttons/commands to still work
+        await self._enqueue_item(
+            sonos_player, self.mass.streams.get_command_url(player_id, "next"), None
+        )
         # optimistically set this timestamp to help figure out elapsed time later
         now = time.time()
         sonos_player.playback_started = now
-        sonos_player.player.elapsed_time = 0
-        sonos_player.player.elapsed_time_last_updated = now
+        mass_player.elapsed_time = 0
+        mass_player.elapsed_time_last_updated = now
 
     async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem):
         """
@@ -553,11 +631,11 @@ class SonosPlayerProvider(PlayerProvider):
         )
         # set crossfade according to player setting
         crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE)
-        if sonos_player.soco.cross_fade != crossfade:
+        if sonos_player.soco_device.cross_fade != crossfade:
 
             def set_crossfade():
                 with suppress(Exception):
-                    sonos_player.soco.cross_fade = crossfade
+                    sonos_player.soco_device.cross_fade = crossfade
 
             await asyncio.to_thread(set_crossfade)
 
@@ -586,7 +664,7 @@ class SonosPlayerProvider(PlayerProvider):
             # based on when we last received info from the device
             await sonos_player.check_poll()
             # always update the attributes
-            await self._update_player(sonos_player, signal_update=False)
+            await self.update_player(sonos_player, signal_update=False)
         except ConnectionResetError as err:
             raise PlayerUnavailableError from err
 
@@ -602,13 +680,10 @@ class SonosPlayerProvider(PlayerProvider):
             )
             if discovered_devices is None:
                 discovered_devices = set()
-            new_device_ids = {item.uid for item in discovered_devices}
-            cur_player_ids = set(self.sonosplayers.keys())
-            added_devices = new_device_ids.difference(cur_player_ids)
 
             # process new players
             for device in discovered_devices:
-                if device.uid not in added_devices:
+                if (existing := self.mass.players.get(device.uid)) and existing.available:
                     continue
                 try:
                     await self._device_discovered(device)
@@ -628,22 +703,22 @@ class SonosPlayerProvider(PlayerProvider):
     async def _device_discovered(self, soco_device: soco.SoCo) -> None:
         """Handle discovered Sonos player."""
         player_id = soco_device.uid
-
         enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True)
         if not enabled:
             self.logger.debug("Ignoring disabled player: %s", player_id)
             return
 
-        speaker_info = await asyncio.to_thread(soco_device.get_speaker_info, True)
-        assert player_id not in self.sonosplayers
-
         if soco_device not in soco_device.visible_zones:
             return
 
-        sonos_player = SonosPlayer(
-            player_id=player_id,
-            soco=soco_device,
-            player=Player(
+        if not (sonos_player := self.sonosplayers.get(player_id)):
+            self.sonosplayers[player_id] = sonos_player = SonosPlayer(
+                self,
+                soco_device,
+            )
+
+        if not (mass_player := self.mass.players.get(player_id)):
+            mass_player = Player(
                 player_id=soco_device.uid,
                 provider=self.domain,
                 type=PlayerType.PLAYER,
@@ -651,121 +726,55 @@ class SonosPlayerProvider(PlayerProvider):
                 available=True,
                 powered=False,
                 supported_features=PLAYER_FEATURES,
-                device_info=DeviceInfo(
-                    model=speaker_info["model_name"],
-                    address=soco_device.ip_address,
-                    manufacturer=self.name,
-                ),
-                max_sample_rate=441000,
+                device_info=DeviceInfo(),
+                max_sample_rate=44100,
                 supports_24bit=False,
-            ),
-            speaker_info=speaker_info,
-            speaker_info_updated=time.time(),
-        )
-        if speaker_info["model_name"] in HIRES_MODELS:
-            sonos_player.player.max_sample_rate = 48000
-            sonos_player.player.supports_24bit = True
-
-        # poll all endpoints once and update attributes
-        await sonos_player.check_poll()
-        sonos_player.update_attributes()
-
-        # handle subscriptions to events
-        def subscribe(service, _callback):
-            queue = ProcessSonosEventQueue(sonos_player, _callback)
-            sub = service.subscribe(auto_renew=True, event_queue=queue)
-            sonos_player.subscriptions.append(sub)
-
-        subscribe(soco_device.avTransport, self._handle_av_transport_event)
-        subscribe(soco_device.renderingControl, self._handle_rendering_control_event)
-        subscribe(soco_device.zoneGroupTopology, self._handle_zone_group_topology_event)
-
-        self.sonosplayers[player_id] = sonos_player
-
-        self.mass.players.register_or_update(sonos_player.player)
-
-    def _handle_av_transport_event(self, sonos_player: SonosPlayer, event: SonosEvent):
-        """Handle a soco.SoCo AVTransport event."""
-        if self.mass.closing:
-            return
-        self.logger.debug("Received AVTransport event for Player %s", sonos_player.soco.player_name)
-
-        if "transport_state" in event.variables:
-            new_state = event.variables["transport_state"]
-            if new_state == "TRANSITIONING":
-                return
-            sonos_player.transport_info["current_transport_state"] = new_state
+            )
 
-        if "current_track_uri" in event.variables:
-            sonos_player.transport_info["uri"] = event.variables["current_track_uri"]
+        await sonos_player.reconnect(soco_device)
 
-        sonos_player.transport_info_updated = time.time()
-        asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop)
+        if sonos_player.speaker_info["model_name"] in HIRES_MODELS:
+            mass_player.max_sample_rate = 48000
+            mass_player.supports_24bit = True
 
-    def _handle_rendering_control_event(self, sonos_player: SonosPlayer, event: SonosEvent):
-        """Handle a soco.SoCo RenderingControl event."""
-        if self.mass.closing:
-            return
-        self.logger.debug(
-            "Received RenderingControl event for Player %s",
-            sonos_player.soco.player_name,
+        mass_player.device_info = DeviceInfo(
+            model=sonos_player.speaker_info["model_name"],
+            address=sonos_player.soco_device.ip_address,
+            manufacturer="SONOS",
         )
-        if "volume" in event.variables:
-            sonos_player.rendering_control_info["volume"] = event.variables["volume"]["Master"]
-        if "mute" in event.variables:
-            sonos_player.rendering_control_info["mute"] = bool(event.variables["mute"]["Master"])
-        sonos_player.rendering_control_info_updated = time.time()
-        asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop)
 
-    def _handle_zone_group_topology_event(
-        self, sonos_player: SonosPlayer, event: SonosEvent  # noqa: ARG002
-    ):
-        """Handle a soco.SoCo ZoneGroupTopology event."""
-        if self.mass.closing:
-            return
-        self.logger.debug(
-            "Received ZoneGroupTopology event for Player %s",
-            sonos_player.soco.player_name,
-        )
-        sonos_player.group_info = sonos_player.soco.group
-        sonos_player.group_info_updated = time.time()
-        asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop)
+        self.mass.players.register_or_update(mass_player)
 
     async def _enqueue_item(
         self,
         sonos_player: SonosPlayer,
         url: str,
-        queue_item: QueueItem,
+        queue_item: QueueItem | None,
     ) -> None:
         """Enqueue a queue item to the Sonos player Queue."""
         metadata = create_didl_metadata(self.mass, url, queue_item)
         await asyncio.to_thread(
-            sonos_player.soco.avTransport.AddURIToQueue,
-            [
-                ("InstanceID", 0),
-                ("EnqueuedURI", url),
-                ("EnqueuedURIMetaData", metadata),
-                ("DesiredFirstTrackNumberEnqueued", 0),
-                ("EnqueueAsNext", 0),
-            ],
+            sonos_player.soco_device.avTransport.SetNextAVTransportURI,
+            [("InstanceID", 0), ("NextURI", url), ("NextURIMetaData", metadata)],
             timeout=60,
         )
-        self.logger.debug(
-            "Enqued track (%s) to player %s",
+        self.logger.info(
+            "Enqued next track (%s) to player %s",
             queue_item.name if queue_item else url,
-            sonos_player.player.display_name,
+            sonos_player.soco_device.player_name,
         )
 
-    async def _update_player(self, sonos_player: SonosPlayer, signal_update: bool = True) -> None:
+    async def update_player(self, sonos_player: SonosPlayer, signal_update: bool = True) -> None:
         """Update Sonos Player."""
-        prev_url = sonos_player.player.current_item_id
-        prev_state = sonos_player.player.state
+        mass_player = self.mass.players.get(sonos_player.player_id)
+        prev_url = mass_player.current_item_id
+        prev_state = mass_player.state
         sonos_player.update_attributes()
-        sonos_player.player.can_sync_with = tuple(
+        mass_player.can_sync_with = tuple(
             x for x in self.sonosplayers if x != sonos_player.player_id
         )
-        current_url = sonos_player.player.current_item_id
-        current_state = sonos_player.player.state
+        current_url = mass_player.current_item_id
+        current_state = mass_player.state
 
         if (prev_url != current_url) or (prev_state != current_state):
             # fetch track details on state or url change
@@ -779,7 +788,7 @@ class SonosPlayerProvider(PlayerProvider):
             # send update to the player manager right away only if we are triggered from an event
             # when we're just updating from a manual poll, the player manager
             # will detect changes to the player object itself
-            self.mass.players.update(sonos_player.player_id)
+            self.mass.players.update(mass_player.player_id)
 
 
 def _convert_state(sonos_state: str) -> PlayerState:
@@ -805,14 +814,12 @@ class ProcessSonosEventQueue:
 
     def __init__(
         self,
-        sonos_player: SonosPlayer,
-        callback_handler: callable[[SonosPlayer, dict], None],
+        callback_handler: callable[[dict], None],
     ) -> None:
         """Initialize Sonos event queue."""
         self._callback_handler = callback_handler
-        self._sonos_player = sonos_player
 
     def put(self, info: Any, block=True, timeout=None) -> None:  # noqa: ARG002
         """Process event."""
         # noqa: ARG001
-        self._callback_handler(self._sonos_player, info)
+        self._callback_handler(info)