feat(android): cache-only audio path when ServerHealth != Healthy
android / Build + lint + test (push) Successful in 4m58s
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user