Release: web UX overhaul + Android native port + server polish #59

Merged
bvandeusen merged 298 commits from dev into main 2026-06-01 16:11:21 -04:00
Showing only changes of commit 7b619152ab - Show all commits
@@ -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