feat(android): cache-only audio path when ServerHealth != Healthy
android / Build + lint + test (push) Successful in 4m58s

Phase 3 of #618. Wraps the OkHttpDataSource upstream of CacheDataSource with OfflineGatedDataSource. CacheDataSource only consults the upstream factory on a cache miss, so playback of cached audio is unaffected. Offline tap on a non-cached track now throws OfflineException immediately (subclass of IOException for ExoPlayer's PlaybackException to wrap) instead of waiting on a multi-second OkHttp timeout. AudioPrefetcher keeps its own ungated upstream -- writes fail silently when offline, no user-visible impact.
This commit is contained in:
2026-06-04 11:27:11 -04:00
parent 80a6be25aa
commit fced6b681e
2 changed files with 72 additions and 1 deletions
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.player
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import java.io.IOException
import java.io.InterruptedIOException
/**
* DataSource wrapper that fails the network read immediately when
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only
* calls this upstream factory on cache misses, so playback of cached audio is
* unaffected -- only "tap a non-cached track while offline" hits this branch
* and gets a fast, meaningful error instead of a multi-second network timeout
* (which then surfaced as a silent decode failure to the user).
*
* Wrapping rather than substituting the OkHttp data source lets the cache
* write path remain intact for when health returns and we DO want to fetch:
* we keep the same upstream all the time, just gate `open()`.
*/
class OfflineGatedDataSource(
private val delegate: DataSource,
private val health: ServerHealthController,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
when (health.state.value) {
ServerHealth.Offline -> throw OfflineException(
"Track not in the on-device cache and the device is offline.",
)
ServerHealth.ServerDown -> throw OfflineException(
"Track not in the on-device cache and the Minstrel server is unreachable.",
)
ServerHealth.Healthy -> Unit
}
return delegate.open(dataSpec)
}
override fun close() = delegate.close()
override fun getUri() = delegate.uri
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
delegate.read(buffer, offset, length)
override fun addTransferListener(transferListener: TransferListener) =
delegate.addTransferListener(transferListener)
override fun getResponseHeaders() = delegate.responseHeaders
}
class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory,
private val health: ServerHealthController,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health)
}
/**
* Signals the audio-source error path that the request was denied because the
* device is offline / the server is unreachable. ExoPlayer's [androidx.media3
* .common.PlaybackException] catches it via [InterruptedIOException]'s
* `IOException` ancestor and surfaces it as a SOURCE error, which then flows
* through the existing [PlaybackErrorReporter] -> snackbar path.
*/
class OfflineException(message: String) : IOException(message)
@@ -52,6 +52,7 @@ class PlayerFactory @Inject constructor(
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -83,9 +84,14 @@ class PlayerFactory @Inject constructor(
private fun buildExoPlayer(): ExoPlayer {
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
// Gate network reads on ServerHealth so a cache miss while offline
// fails fast with an OfflineException instead of hitting an OkHttp
// timeout. CacheDataSource only consults the upstream factory on a
// cache miss, so playback of cached audio is unaffected.
val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth)
val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(httpDataSource)
.setUpstreamDataSourceFactory(gatedUpstream)
.setCacheWriteDataSinkFactory(
CacheDataSink.Factory()
.setCache(simpleCache)