Fix PlayerQueue debounce logic for next/previous command
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 25 Feb 2026 13:58:36 +0000 (14:58 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 25 Feb 2026 13:58:36 +0000 (14:58 +0100)
music_assistant/controllers/player_queues.py

index b0209d6ddf65e23bb01bea64fe555979d9aacf22..1bfab8950b24cc0e27116fa6e4cdd016e7fe6461 100644 (file)
@@ -484,6 +484,8 @@ class PlayerQueuesController(CoreController):
             to account for playback history per user when the play_media is
             called from a shared context (like a web hook or automation).
         """
+        # cancel any pending play_index calls for this queue to prevent conflicts
+        self.mass.cancel_timer(f"queue_play_index_{queue_id}")
         # ruff: noqa: PLR0915
         # we use a contextvar to bypass the throttler for this asyncio task/context
         # this makes sure that playback has priority over other requests that may be
@@ -802,6 +804,8 @@ class PlayerQueuesController(CoreController):
 
         - queue_id: queue_id of the playerqueue to handle the command.
         """
+        # cancel any pending play_index calls for this queue to prevent conflicts
+        self.mass.cancel_timer(f"queue_play_index_{queue_id}")
         queue_player = self.mass.players.get_player(queue_id, True)
         if queue_player is None:
             raise PlayerUnavailableError(f"Player {queue_id} is not available")
@@ -843,6 +847,8 @@ class PlayerQueuesController(CoreController):
 
         - queue_id: queue_id of the playerqueue to handle the command.
         """
+        # cancel any pending play_index calls for this queue to prevent conflicts
+        self.mass.cancel_timer(f"queue_play_index_{queue_id}")
         if not (queue := self._queues.get(queue_id)):
             return
         queue_active = queue.active
@@ -900,23 +906,33 @@ class PlayerQueuesController(CoreController):
         """
         if (queue := self.get(queue_id)) is None or not queue.active:
             raise InvalidCommand(f"Queue {queue_id} is not active")
+        # we set a flag to notify the update logic that we're transitioning to a new track
+        # NOTE that this flag is reset in the play_index method
+        self._transitioning_players.add(queue_id)
         idx = self._queues[queue_id].current_index
         if idx is None:
             self.logger.warning("Queue %s has no current index", queue.display_name)
             return
-        attempts = 5
-        while attempts:
-            try:
-                if (next_index := self._get_next_index(queue_id, idx, True)) is not None:
-                    await self.play_index(queue_id, next_index, debounce=True)
-                break
-            except MediaNotFoundError:
-                self.logger.warning(
-                    "Failed to fetch next track for queue %s - trying next item",
-                    queue.display_name,
-                )
-                idx += 1
-                attempts -= 1
+        next_index = self._get_next_index(queue_id, idx, True)
+        if next_index is None:
+            return
+
+        # immediately update current item so UI shows the new track right away
+        queue.current_index = next_index
+        queue.current_item = self.get_item(queue_id, next_index)
+        queue.elapsed_time = 0
+        queue.elapsed_time_last_updated = time.time()
+        self.signal_update(queue_id)
+        if queue_player := self.mass.players.get_player(queue_id, True):
+            # also update player so it can update its 'current_media'
+            queue_player.update_state()
+
+        # debounce rapid next button presses using call_later
+        self.mass.call_later(
+            1,
+            self.play_index(queue_id, next_index),
+            task_id=f"queue_play_index_{queue_id}",
+        )
 
     @api_command("player_queues/previous")
     async def previous(self, queue_id: str) -> None:
@@ -926,15 +942,34 @@ class PlayerQueuesController(CoreController):
         """
         if (queue := self.get(queue_id)) is None or not queue.active:
             raise InvalidCommand(f"Queue {queue_id} is not active")
+        # we set a flag to notify the update logic that we're transitioning to a new track
+        # NOTE that this flag is reset in the play_index method
+        self._transitioning_players.add(queue_id)
         current_index = self._queues[queue_id].current_index
         if current_index is None:
             return
-        next_index = int(current_index)
+        prev_index = int(current_index)
         # restart current track if current track has played longer than 4
         # otherwise skip to previous track
         if self._queues[queue_id].elapsed_time < 5:
-            next_index = max(current_index - 1, 0)
-        await self.play_index(queue_id, next_index, debounce=True)
+            prev_index = max(current_index - 1, 0)
+
+        # immediately update current item so UI shows the new track right away
+        queue.current_index = prev_index
+        queue.current_item = self.get_item(queue_id, prev_index)
+        queue.elapsed_time = 0
+        queue.elapsed_time_last_updated = time.time()
+        self.signal_update(queue_id)
+        if queue_player := self.mass.players.get_player(queue_id, True):
+            # also update player so it can update its 'current_media'
+            queue_player.update_state()
+
+        # debounce rapid previous button presses using call_later
+        self.mass.call_later(
+            1,
+            self.play_index(queue_id, prev_index),
+            task_id=f"queue_play_index_{queue_id}",
+        )
 
     @api_command("player_queues/skip")
     async def skip(self, queue_id: str, seconds: int = 10) -> None:
@@ -1027,9 +1062,12 @@ class PlayerQueuesController(CoreController):
         index: int | str,
         seek_position: int = 0,
         fade_in: bool = False,
-        debounce: bool = False,
     ) -> None:
         """Play item at index (or item_id) X in queue."""
+        # cancel any pending play_index calls for this queue to prevent conflicts
+        self.mass.cancel_timer(f"queue_play_index_{queue_id}")
+        # we set a flag to notify the update logic that we're transitioning to a new track
+        self._transitioning_players.add(queue_id)
         queue = self._queues[queue_id]
         queue.resume_pos = 0
         if isinstance(index, str):
@@ -1038,13 +1076,6 @@ class PlayerQueuesController(CoreController):
                 raise InvalidDataError(f"Item {index} not found in queue")
             index = temp_index
         # At this point index is guaranteed to be int
-        queue.current_index = index
-        # update current item and elapsed time and signal update
-        # this way the UI knows immediately that a new item is loading
-        queue.current_item = self.get_item(queue_id, index)
-        queue.elapsed_time = seek_position
-        queue.elapsed_time_last_updated = time.time()
-        self.signal_update(queue_id)
         queue.index_in_buffer = index
         queue.flow_mode_stream_log = []
         target_player = self.mass.players.get_player(queue_id)
@@ -1061,72 +1092,49 @@ class PlayerQueuesController(CoreController):
         ):
             seek_position = max(0, int((resume_position_ms - 500) / 1000))
 
-        # send play_media request to player
-        # NOTE that we debounce this a bit to account for someone hitting the next button
-        # like a madman. This will prevent the player from being overloaded with requests.
-        async def _play_index(index: int, debounce: bool) -> None:
-            for attempt in range(5):
-                try:
-                    queue_item = self.get_item(queue_id, index)
-                    if not queue_item:
-                        continue  # guard
-                    await self._load_item(
-                        queue_item,
-                        self._get_next_index(queue_id, index),
-                        is_start=True,
-                        seek_position=seek_position if attempt == 0 else 0,
-                        fade_in=fade_in if attempt == 0 else False,
+        # try to load the item, retry with next item if it fails
+        for attempt in range(5):
+            try:
+                queue_item = self.get_item(queue_id, index)
+                if not queue_item:
+                    continue  # guard
+                await self._load_item(
+                    queue_item,
+                    self._get_next_index(queue_id, index),
+                    is_start=True,
+                    seek_position=seek_position if attempt == 0 else 0,
+                    fade_in=fade_in if attempt == 0 else False,
+                )
+                # if we reach this point, loading the item succeeded, break the loop
+                queue.current_index = index
+                queue.current_item = queue_item
+                break
+            except (MediaNotFoundError, AudioError):
+                # the requested index can not be played.
+                if queue_item:
+                    self.logger.warning(
+                        "Skipping unplayable item %s (%s)",
+                        queue_item.name,
+                        queue_item.uri,
                     )
-                    # if we reach this point, loading the item succeeded, break the loop
-                    queue.current_index = index
-                    queue.current_item = queue_item
-                    break
-                except (MediaNotFoundError, AudioError):
-                    # the requested index can not be played.
-                    if queue_item:
-                        self.logger.warning(
-                            "Skipping unplayable item %s (%s)",
-                            queue_item.name,
-                            queue_item.uri,
-                        )
-                        queue_item.available = False
-                    next_index = self._get_next_index(queue_id, index, allow_repeat=False)
-                    if next_index is None:
-                        raise MediaNotFoundError("No next item available")
-                    index = next_index
-            else:
-                # all attempts to find a playable item failed
-                raise MediaNotFoundError("No playable item found to start playback")
-
-            # Reset flow_mode - the streams controller will set it if flow mode is used.
-            queue.flow_mode = False
-            await asyncio.sleep(0.5 if debounce else 0.1)
-            await self.mass.players.play_media(
-                player_id=queue_id,
-                media=await self.player_media_from_queue_item(queue_item),
-            )
-            queue.current_index = index
-            queue.current_item = queue_item
-            await asyncio.sleep(2)
-            self._transitioning_players.discard(queue_id)
-
-        # we set a flag to notify the update logic that we're transitioning to a new track
-        self._transitioning_players.add(queue_id)
-
-        # we debounce the play_index command to handle the case where someone
-        # is spamming next/previous on the player
-        task_id = f"play_index_{queue_id}"
-        if existing_task := self.mass.get_task(task_id):
-            existing_task.cancel()
-            with suppress(asyncio.CancelledError):
-                await existing_task
-        task = self.mass.create_task(
-            _play_index,
-            index,
-            debounce,
-            task_id=task_id,
+                    queue_item.available = False
+                next_index = self._get_next_index(queue_id, index, allow_repeat=False)
+                if next_index is None:
+                    raise MediaNotFoundError("No next item available")
+                index = next_index
+        else:
+            # all attempts to find a playable item failed
+            raise MediaNotFoundError("No playable item found to start playback")
+
+        # Reset flow_mode - the streams controller will set it if flow mode is used.
+        queue.flow_mode = False
+        await self.mass.players.play_media(
+            player_id=queue_id,
+            media=await self.player_media_from_queue_item(queue_item),
         )
-        await task
+        queue.current_index = index
+        queue.current_item = queue_item
+        self._transitioning_players.discard(queue_id)
         self.signal_update(queue_id)
 
     @api_command("player_queues/transfer")