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
- 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")
- 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
"""
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:
"""
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:
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):
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)
):
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")