From eb53d1e0ad5c8bfd349ad3eb4be82e97952d4377 Mon Sep 17 00:00:00 2001 From: David Madl Date: Sun, 24 May 2026 10:41:51 +0200 Subject: [PATCH] fix: thread-safe player position logging --- .../player/playback/PlaybackService.kt | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt index 4082cbd..f6372d4 100644 --- a/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt +++ b/app/src/main/java/at/lockstep/player/playback/PlaybackService.kt @@ -50,6 +50,7 @@ class PlaybackService : Service() { private var player: ExoPlayer? = null private var positionPollJob: Job? = null + private var positionCachePollJob: Job? = null private val _uiState = MutableStateFlow(PlaybackUiState.initial()) val uiState: StateFlow = _uiState.asStateFlow() @@ -64,6 +65,10 @@ class PlaybackService : Service() { private var queue: List = emptyList() private var index: Int = 0 + /** Updated on the main thread whenever progress is read from ExoPlayer — safe for sensor threads. */ + @Volatile + private var cachedPlaybackPositionMs: Long = 0L + private val playerListener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { @@ -215,11 +220,21 @@ class PlaybackService : Service() { } } } + positionCachePollJob?.cancel() + positionCachePollJob = + scope.launch { + while (isActive) { + delay(POSITION_CACHE_INTERVAL_MS) + refreshCachedPlaybackPositionMs() + } + } } private fun releasePlayer() { positionPollJob?.cancel() positionPollJob = null + positionCachePollJob?.cancel() + positionCachePollJob = null player?.removeListener(playerListener) player?.release() player = null @@ -321,13 +336,24 @@ class PlaybackService : Service() { return hint } + private fun refreshCachedPlaybackPositionMs() { + val p = player ?: return + if (queue.isEmpty()) { + cachedPlaybackPositionMs = 0L + return + } + val durationMs = currentDurationMs().coerceAtLeast(1L) + cachedPlaybackPositionMs = p.currentPosition.coerceIn(0L, durationMs) + } + private fun updateProgressFromPlayer() { val p = player ?: return - val durationMs = currentDurationMs().coerceAtLeast(1L) - val positionMs = p.currentPosition.coerceIn(0L, durationMs) + refreshCachedPlaybackPositionMs() if (queue.isEmpty()) { return } + val durationMs = currentDurationMs().coerceAtLeast(1L) + val positionMs = cachedPlaybackPositionMs val progress = (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) val durationSec = (durationMs / 1000L).toInt().coerceAtLeast(1) _uiState.value = @@ -413,11 +439,9 @@ class PlaybackService : Service() { /** * Milliseconds from the start of the current track — same timebase as [ExoPlayer.getCurrentPosition]. - * May be called from any thread per Media3 [Player] contract. + * Reads a main-thread cache so callers on background threads (e.g. run-data sensor collection) stay safe. */ - fun getPlaybackPositionMs(): Long { - return player?.currentPosition ?: 0L - } + fun getPlaybackPositionMs(): Long = cachedPlaybackPositionMs private fun refreshForegroundNotification() { if (queue.isEmpty()) return @@ -494,6 +518,7 @@ class PlaybackService : Service() { override fun onDestroy() { positionPollJob?.cancel() + positionCachePollJob?.cancel() releasePlayer() mediaSession.run { isActive = false @@ -538,6 +563,7 @@ class PlaybackService : Service() { companion object { private const val UPDATE_INTERVAL_MS = 250L + private const val POSITION_CACHE_INTERVAL_MS = 20L private const val DEFAULT_DURATION_HINT_MS = 180_000 const val ACTION_START_PLAYLIST = "at.lockstep.player.action.START_PLAYLIST"