Android v1 polish + Web UI flavor pass #65

Merged
bvandeusen merged 13 commits from dev into main 2026-06-01 20:17:49 -04:00
2 changed files with 154 additions and 0 deletions
Showing only changes of commit 8df41c3bed - Show all commits
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
@@ -103,6 +104,15 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
* AuthStore.cacheSettings and pins the next-N tracks into
* SimpleCache via CacheWriter. Without this @Inject the
* prefetchWindow setting would be inert and skips would re-fetch.
*/
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/**
* Same construct-the-singleton trick — VersionCheckController's
* init block starts a 5-min poll loop against /healthz so the
@@ -0,0 +1,144 @@
package com.fabledsword.minstrel.player
import android.net.Uri
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheWriter
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
/**
* Pre-downloads the next-N tracks in the queue into the shared Media3
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
* natural advance plays from disk instead of waiting on a fresh HTTP
* connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
* watches the player's current track, walks forward by
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
* a track is already fully resident, so the reconcile is cheap on
* every queue/index change.
*
* Eager-constructed via the construct-the-singleton trick in
* [com.fabledsword.minstrel.MinstrelApplication]; the init block wires
* the observer.
*/
@Singleton
class AudioPrefetcher @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val authStore: AuthStore,
private val okHttpClient: OkHttpClient,
) {
// Reuse the same cache + upstream chain the player uses. Without the
// SimpleCache reference here the prefetcher would write to a
// disjoint store and playback would never read what we pinned.
private val cacheDataSourceFactory: CacheDataSource.Factory =
CacheDataSource.Factory()
.setCache(playerFactory.simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
// trackId -> in-flight job. Reconcile cancels jobs whose track has
// dropped out of the window (skip-prev, queue rebuild) so a stale
// prefetch doesn't keep the network busy.
private val activeJobs = mutableMapOf<String, Job>()
private val mutex = Mutex()
init {
scope.launch {
combine(
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
playerController.uiState.map { it.queueIndex },
authStore.cacheSettings.map { it.prefetchWindow },
) { queue, index, window -> Triple(queue, index, window) }
.distinctUntilChanged()
.collect { (queue, index, window) -> reconcile(queue, index, window) }
}
}
private suspend fun reconcile(
queue: List<Pair<String, String>>,
index: Int,
window: Int,
) {
mutex.withLock {
if (index < 0 || queue.isEmpty() || window <= 0) {
cancelAllLocked()
return
}
// Exclude the currently-playing track (it's loaded by the
// player itself) and walk `window` tracks forward.
val firstIdx = (index + 1).coerceAtMost(queue.size)
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
if (firstIdx > lastIdx) {
cancelAllLocked()
return
}
val targets = queue.subList(firstIdx, lastIdx + 1)
val targetIds = targets.mapTo(mutableSetOf()) { it.first }
// Cancel jobs for tracks that have slid out of the window.
activeJobs.entries
.filter { it.key !in targetIds }
.toList()
.forEach { (id, job) ->
job.cancel()
activeJobs.remove(id)
}
// Start prefetches for new arrivals. Skip blank URLs (these
// come from minimal TrackRefs synthesized from playlist rows
// when the upstream track was removed from the library).
for ((trackId, streamUrl) in targets) {
if (trackId in activeJobs) continue
if (streamUrl.isBlank()) continue
val job = scope.launch(Dispatchers.IO) {
runCatching { prefetchOne(trackId, streamUrl) }
mutex.withLock { activeJobs.remove(trackId) }
}
activeJobs[trackId] = job
}
}
}
private fun cancelAllLocked() {
activeJobs.values.forEach { it.cancel() }
activeJobs.clear()
}
private fun prefetchOne(trackId: String, streamUrl: String) {
// Match PlayerController.toMediaItem's resolveServerUrl +
// setCustomCacheKey(id) so the cached bytes are keyed exactly
// like a normal playback request — otherwise the player would
// miss them on read-through.
val resolved = resolveServerUrl(streamUrl) ?: streamUrl
val dataSpec = DataSpec.Builder()
.setUri(Uri.parse(resolved))
.setKey(trackId)
.build()
val writer = CacheWriter(
cacheDataSourceFactory.createDataSource(),
dataSpec,
/* temporaryBuffer = */ null,
/* progressListener = */ null,
)
// Blocking, but we're on Dispatchers.IO. Throws on network
// failure — outer runCatching swallows so a single failed
// prefetch doesn't trip the whole reconcile.
writer.cache()
}
}