Fix issues with progress bar jumps and time overflow (#2959)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Tue, 13 Jan 2026 14:18:31 +0000 (15:18 +0100)
committerGitHub <noreply@github.com>
Tue, 13 Jan 2026 14:18:31 +0000 (15:18 +0100)
* Progress

* Progress

* Fix duplicate playlog entries on stream restart

* Use model for queuetimeupdate

* Cleanup

* Update music_assistant/controllers/streams/streams_controller.py

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
* Feedback

* Feedback

---------

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams/streams_controller.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/stream_session.py
music_assistant/providers/squeezelite/player.py

index 5bcd3899f233701c3f947944c9f1796b2615d98c..6edcefa1e56d25ee367bb30dee7001164ba504ea 100644 (file)
@@ -677,6 +677,7 @@ class PlayerQueuesController(CoreController):
         queue.current_index = None
         queue.current_item = None
         queue.elapsed_time = 0
+        queue.elapsed_time_last_updated = time.time()
         queue.index_in_buffer = None
         self.update_items(queue_id, [])
 
@@ -924,6 +925,7 @@ class PlayerQueuesController(CoreController):
         # 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 = []
index 8989641228a46b7ff8462c051625fced3b35ae11..7f2ee97591fd387843ced730b3bf00a8413d62c0 100644 (file)
@@ -590,6 +590,8 @@ class StreamsController(CoreController):
         if not start_queue_item:
             raise web.HTTPNotFound(reason=f"Unknown Queue item: {start_queue_item_id}")
 
+        queue.flow_mode_stream_log = []
+
         # select the highest possible PCM settings for this player
         flow_pcm_format = await self._select_flow_format(queue_player)
 
index 18599f141b4b7911ed7ba0aec2f55ace2d5d35db..28ee5a2f1d8ebef8de96b5ca5d8b5f16d7e56406 100644 (file)
@@ -132,7 +132,12 @@ class AirPlayPlayer(Player):
         """Return the corrected elapsed time accounting for stream session restarts."""
         if not self.stream or not self.stream.session:
             return super().corrected_elapsed_time or 0.0
-        return time.time() - self.stream.session.last_stream_started
+        session = self.stream.session
+        elapsed = time.time() - session.last_stream_started - session.total_pause_time
+        if session.last_paused is not None:
+            current_pause = time.time() - session.last_paused
+            elapsed -= current_pause
+        return max(0.0, elapsed)
 
     async def get_config_entries(
         self,
@@ -592,7 +597,16 @@ class AirPlayPlayer(Player):
     ) -> None:
         """Set the playback state from stream (RAOP or AirPlay2)."""
         if state is not None:
+            prev_state = self._attr_playback_state
             self._attr_playback_state = state
+            if self.stream and self.stream.session:
+                if prev_state == PlaybackState.PLAYING and state != PlaybackState.PLAYING:
+                    self.stream.session.last_paused = time.time()
+                elif prev_state != PlaybackState.PLAYING and state == PlaybackState.PLAYING:
+                    if self.stream.session.last_paused is not None:
+                        pause_duration = time.time() - self.stream.session.last_paused
+                        self.stream.session.total_pause_time += pause_duration
+                        self.stream.session.last_paused = None
         if elapsed_time is not None:
             self._attr_elapsed_time = elapsed_time
             self._attr_elapsed_time_last_updated = time.time()
index ca1d5a0031201c41a887cbcef261f3ef0da1b8a4..775bda66c6ce538a0626fbc442c960b20bc4afda 100644 (file)
@@ -65,6 +65,8 @@ class AirPlayStreamSession:
         # because we reuse an existing stream session for new play_media requests,
         # we need to track when the last stream was started
         self.last_stream_started: float = 0.0
+        self.total_pause_time: float = 0.0
+        self.last_paused: float | None = None
         self._clients_ready = asyncio.Event()
         self._first_chunk_received = asyncio.Event()
 
@@ -206,6 +208,8 @@ class AirPlayStreamSession:
             old_audio_source_task.cancel()
             self._audio_source_task = new_audio_source_task
         self.last_stream_started = time.time() + self.wait_start
+        self.total_pause_time = 0.0
+        self.last_paused = None
         for sync_client in self.sync_clients:
             sync_client.set_state_from_stream(state=None, elapsed_time=0)
         # ensure we cleanly wait for the old audio source task to finish
index 17882e50b24bc3a0134f55ec93f7b3293a956311..8a6a62f27c5e184be02db92e44c51c6ecc826896 100644 (file)
@@ -369,6 +369,7 @@ class SqueezelitePlayer(Player):
         self._attr_available = self.client.connected
         self._attr_name = self.client.name
         self._attr_powered = self.client.powered
+        old_state = self._attr_playback_state
         self._attr_playback_state = STATE_MAP[self.client.state]
         self._attr_volume_level = self.client.volume_level
         self._attr_volume_muted = self.client.muted
@@ -377,8 +378,13 @@ class SqueezelitePlayer(Player):
             ip_address=self.client.device_address,
             manufacturer=self.client.device_type,
         )
-        self._attr_elapsed_time = self.client.elapsed_seconds
-        self._attr_elapsed_time_last_updated = time.time()
+        if (
+            old_state != PlaybackState.PLAYING
+            and self._attr_playback_state == PlaybackState.PLAYING
+        ):
+            # Invalidate elapsed time interpolation to avoid jumps when resuming from pause/stop
+            # We need this because some players (e.g. WiiM) keep sending increasing elapsed time
+            self._attr_elapsed_time_last_updated = time.time()
         # Update current media if available
         if self.client.current_media and (metadata := self.client.current_media.metadata):
             self._attr_current_media = PlayerMedia(
@@ -458,8 +464,10 @@ class SqueezelitePlayer(Player):
 
     def _handle_player_heartbeat(self) -> None:
         """Process SlimClient elapsed_time update."""
-        if self.client.state == SlimPlayerState.STOPPED:
-            # ignore server heartbeats when stopped
+        if self.playback_state != PlaybackState.PLAYING:
+            # ignore server heartbeats when not playing
+            # Some players keep sending heartbeat with increasing elapsed time
+            # even when paused (e.g. WiiM)
             return
         # elapsed time change on the player will be auto picked up
         # by the player manager.