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 cacheConfig: CacheConfig,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
|
||||||
) {
|
) {
|
||||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||||
|
|
||||||
@@ -83,9 +84,14 @@ class PlayerFactory @Inject constructor(
|
|||||||
|
|
||||||
private fun buildExoPlayer(): ExoPlayer {
|
private fun buildExoPlayer(): ExoPlayer {
|
||||||
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
|
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()
|
val cacheDataSource = CacheDataSource.Factory()
|
||||||
.setCache(simpleCache)
|
.setCache(simpleCache)
|
||||||
.setUpstreamDataSourceFactory(httpDataSource)
|
.setUpstreamDataSourceFactory(gatedUpstream)
|
||||||
.setCacheWriteDataSinkFactory(
|
.setCacheWriteDataSinkFactory(
|
||||||
CacheDataSink.Factory()
|
CacheDataSink.Factory()
|
||||||
.setCache(simpleCache)
|
.setCache(simpleCache)
|
||||||
|
|||||||
Reference in New Issue
Block a user