fix(android): periodic position polling for smooth seek bar
android / Build + lint + test (push) Successful in 3m41s
android / Build signed release APK (push) Has been skipped

PlayerController only ran the UI-state copy on Media3 Player.Events
callbacks (track change, pause/play, buffer state) - none of which
fire on normal position advancement during playback. The seek bar
appeared to update every 20-30 seconds, whenever a buffer event
happened to fire, instead of ticking smoothly.

Adds a startPositionPolling() coroutine launched on
Dispatchers.Main.immediate after the controller is connected.
Samples controller.currentPosition + bufferedPosition every 500ms
while isPlaying; skips state updates while paused (positionMs stays
at the last value, which is the correct paused-state behavior).
The poll patches only the position fields via .copy() so stale
event-driven snapshots can't clobber the latest state.

Cadence reference: Flutter via just_audio positionStream runs at
~200ms (audio_handler.dart:75); Spotify / Apple Music sit around
250-500ms in-app. 500ms is the battery-friendly middle ground that
still reads as smooth on a slider. Lock-screen / notification
surfaces are driven by Media3's MediaSession separately and don't
need this poll.
This commit is contained in:
2026-06-01 02:21:52 -04:00
parent 5b050022f1
commit 7b619152ab
@@ -13,12 +13,15 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.resolveServerUrl
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
@@ -193,6 +196,7 @@ class PlayerController @Inject constructor(
return
}
mediaController = controller
startPositionPolling(controller)
controller.addListener(
object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
@@ -235,6 +239,33 @@ class PlayerController @Inject constructor(
)
}
/**
* Periodic `currentPosition` / `bufferedPosition` poller. Media3's
* `Player.Events` callbacks only fire at boundaries (track change,
* pause/play, buffer state), never on normal position advancement,
* so the seek bar would otherwise sit static between events. Polls
* at [POSITION_POLL_INTERVAL_MS]; matches the in-app cadence of
* Spotify / Apple Music / Flutter (just_audio `positionStream` runs
* at ~200ms; this 500ms is a battery-friendly middle ground that
* still reads as smooth on a slider).
*
* The poll only patches position fields via `.copy()` so a stale
* `onEvents` snapshot can't clobber the latest state. MediaController
* is touched only from the main dispatcher.
*/
private fun startPositionPolling(controller: MediaController) {
scope.launch(Dispatchers.Main.immediate) {
while (isActive) {
delay(POSITION_POLL_INTERVAL_MS)
if (!controller.isPlaying) continue
uiStateInternal.value = uiStateInternal.value.copy(
positionMs = controller.currentPosition.coerceAtLeast(0),
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
)
}
}
}
/**
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
* to a suspend function without pulling in `kotlinx-coroutines-guava`
@@ -296,3 +327,10 @@ class PlayerController @Inject constructor(
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
}
}
// 500ms: smooth-enough on a slider without being battery-hostile.
// Flutter (just_audio positionStream) ticks at ~200ms; Spotify / Apple
// Music sit around 250-500ms in-app. Lock-screen / notification
// surfaces are driven by Media3's MediaSession callbacks separately;
// they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L