Fix control of other sources on players + elapsed time
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 27 Oct 2025 20:09:51 +0000 (21:09 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 27 Oct 2025 20:09:51 +0000 (21:09 +0100)
music_assistant/constants.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players/player_controller.py

index 50cf41d81dc3569b1abc4538aa326fdbdaa285e6..35854ac5e130a7de35bc684425e5135e1843fe6e 100644 (file)
@@ -959,6 +959,9 @@ ATTR_ANNOUNCEMENT_IN_PROGRESS: Final[str] = "announcement_in_progress"
 ATTR_PREVIOUS_VOLUME: Final[str] = "previous_volume"
 ATTR_LAST_POLL: Final[str] = "last_poll"
 ATTR_GROUP_MEMBERS: Final[str] = "group_members"
+ATTR_ELAPSED_TIME: Final[str] = "elapsed_time"
+ATTR_ENABLED: Final[str] = "enabled"
+ATTR_AVAILABLE: Final[str] = "available"
 
 # Album type detection patterns
 LIVE_INDICATORS = [
index fc74d02f80518c8c8a5e3d97857af29f412891d8..7e02f5a123641089fd77ebd22b1ae63fd0ae8aa2 100644 (file)
@@ -1244,6 +1244,14 @@ class PlayerQueuesController(CoreController):
         if queue.state == PlaybackState.PLAYING and queue.index_in_buffer is not None:
             # if the queue is playing,
             # ensure to (re)queue the next track because it might have changed
+            if queue.next_item and queue.next_item == self.get_item(
+                queue_id, queue.index_in_buffer
+            ):
+                self.logger.warning(
+                    "Skipping enqueue of next item on queue %s, "
+                    "because the player has already loaded a different item in the buffer",
+                    self._queues[queue_id].display_name,
+                )
             if next_item := self.get_next_item(queue_id, queue.index_in_buffer):
                 self._enqueue_next_item(queue_id, next_item)
 
@@ -1607,9 +1615,6 @@ class PlayerQueuesController(CoreController):
                     retries -= 1
                     await asyncio.sleep(1)
 
-                if next_item := await self.load_next_queue_item(queue_id, item_id_in_buffer):
-                    self._enqueue_next_item(queue_id, next_item)
-
             except QueueEmpty:
                 return
 
@@ -1854,19 +1859,27 @@ class PlayerQueuesController(CoreController):
         changed_keys = get_changed_keys(prev_state, new_state)
         with suppress(KeyError):
             changed_keys.remove("next_item_id")
+
         # return early if nothing changed
         if len(changed_keys) == 0:
             return
 
         # signal update and store state
+        send_update = True
         if changed_keys == {"elapsed_time"}:
-            # do not send full updates if only time was updated
-            self.mass.signal_event(
-                EventType.QUEUE_TIME_UPDATED,
-                object_id=queue_id,
-                data=queue.elapsed_time,
-            )
-        else:
+            # only elapsed time changed, do not send full queue update
+            send_update = False
+            prev_time = prev_state.get("elapsed_time") or 0
+            cur_time = new_state.get("elapsed_time") or 0
+            if abs(cur_time - prev_time) > 2:
+                # send dedicated event for time updates when seeking
+                self.mass.signal_event(
+                    EventType.QUEUE_TIME_UPDATED,
+                    object_id=queue_id,
+                    data=queue.elapsed_time,
+                )
+
+        if send_update:
             self.signal_update(queue_id)
 
         # store the new state
index 9301424cb1a2b618b5fe4a9da0673c3ddca7fb3f..4f5d023878cf0c817006e783a5358a6ddb6dbe86 100644 (file)
@@ -49,6 +49,9 @@ from music_assistant_models.player_control import PlayerControl  # noqa: TC002
 from music_assistant.constants import (
     ANNOUNCE_ALERT_FILE,
     ATTR_ANNOUNCEMENT_IN_PROGRESS,
+    ATTR_AVAILABLE,
+    ATTR_ELAPSED_TIME,
+    ATTR_ENABLED,
     ATTR_FAKE_MUTE,
     ATTR_FAKE_POWER,
     ATTR_FAKE_VOLUME,
@@ -373,7 +376,16 @@ class PlayerController(CoreController):
                 return
 
         if player.playback_state == PlaybackState.PAUSED:
-            # handle command on player directly
+            # handle command on player/source directly
+            active_source = next(
+                (x for x in player.source_list if x.id == player.active_source), None
+            )
+            if active_source and not active_source.can_play_pause:
+                raise PlayerCommandFailed(
+                    "The active source (%s) on player %s does not support play/pause",
+                    active_source.name,
+                    player.display_name,
+                )
             async with self._player_throttlers[player.player_id]:
                 await player.play()
         else:
@@ -399,6 +411,15 @@ class PlayerController(CoreController):
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.pause(active_queue.queue_id)
             return
+
+        # handle command on player/source directly
+        active_source = next((x for x in player.source_list if x.id == player.active_source), None)
+        if active_source and not active_source.can_play_pause:
+            raise PlayerCommandFailed(
+                "The active source (%s) on player %s does not support play/pause",
+                active_source.name,
+                player.display_name,
+            )
         if PlayerFeature.PAUSE not in player.supported_features:
             # if player does not support pause, we need to send stop
             self.logger.debug(
@@ -481,6 +502,15 @@ class PlayerController(CoreController):
         if active_queue := self.get_active_queue(player):
             await self.mass.player_queues.seek(active_queue.queue_id, position)
             return
+
+        # handle command on player/source directly
+        active_source = next((x for x in player.source_list if x.id == player.active_source), None)
+        if active_source and not active_source.can_seek:
+            raise PlayerCommandFailed(
+                "The active source (%s) on player %s does not support seeking",
+                active_source.name,
+                player.display_name,
+            )
         if PlayerFeature.SEEK not in player.supported_features:
             msg = f"Player {player.display_name} does not support seeking"
             raise UnsupportedFeaturedException(msg)
@@ -1507,7 +1537,7 @@ class PlayerController(CoreController):
             return
 
         # ignore updates for disabled players
-        if not player.enabled and "enabled" not in changed_values:
+        if not player.enabled and ATTR_ENABLED not in changed_values:
             return
 
         if len(changed_values) == 0 and not force_update:
@@ -1517,12 +1547,11 @@ class PlayerController(CoreController):
         # always signal update to the playerqueue
         self.mass.player_queues.on_player_update(player, changed_values)
 
-        if changed_values.keys() == {"elapsed_time"} and not force_update:
-            # ignore elapsed_time only changes
-            prev_value = changed_values["elapsed_time"][0] or 0
-            new_value = changed_values["elapsed_time"][1] or 0
-            if abs(prev_value - new_value) < 30:
-                # ignore small changes in elapsed time
+        if changed_values.keys() == {ATTR_ELAPSED_TIME} and not force_update:
+            # ignore small changes in elapsed time
+            prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
+            new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
+            if abs(prev_value - new_value) < 5:
                 return
 
         # handle DSP reload of the leader when grouping/ungrouping
@@ -1541,10 +1570,10 @@ class PlayerController(CoreController):
                     removed_player.update_state()
 
         became_inactive = False
-        if "available" in changed_values:
-            became_inactive = changed_values["available"][1] is False
-        if not became_inactive and "enabled" in changed_values:
-            became_inactive = changed_values["enabled"][1] is False
+        if ATTR_AVAILABLE in changed_values:
+            became_inactive = changed_values[ATTR_AVAILABLE][1] is False
+        if not became_inactive and ATTR_ENABLED in changed_values:
+            became_inactive = changed_values[ATTR_ENABLED][1] is False
         if became_inactive and (player.active_group or player.synced_to):
             self.mass.create_task(self._cleanup_player_memberships(player.player_id))
 
@@ -1772,13 +1801,13 @@ class PlayerController(CoreController):
 
     async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
         """Call (by config manager) when the configuration of a player changes."""
-        player_disabled = "enabled" in changed_keys and not config.enabled
+        player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
         # signal player provider that the player got enabled/disabled
         if player_provider := self.mass.get_provider(config.provider):
             assert isinstance(player_provider, PlayerProvider)  # for type checking
-            if "enabled" in changed_keys and not config.enabled:
+            if ATTR_ENABLED in changed_keys and not config.enabled:
                 player_provider.on_player_disabled(config.player_id)
-            elif "enabled" in changed_keys and config.enabled:
+            elif ATTR_ENABLED in changed_keys and config.enabled:
                 player_provider.on_player_enabled(config.player_id)
         # ensure player state gets updated with any updated config
         if not (player := self.get(config.player_id)):