From fced6b681e3a9007190ff1ba2985167992a35d4e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 11:27:11 -0400 Subject: [PATCH] feat(android): cache-only audio path when ServerHealth != Healthy 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. --- .../minstrel/player/OfflineGatedDataSource.kt | 65 +++++++++++++++++++ .../minstrel/player/PlayerFactory.kt | 8 ++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt new file mode 100644 index 00000000..677a9033 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index dd28ca5e..91176bb6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -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)