diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 6a172a0d..b7782e64 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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.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