playback: cache streamed audio to stop redundant refetches

A streamed MP3 with no Xing/Info/VBRI header gets an estimated duration
from Mp3Extractor. At end of stream the extractor rewrites it, which
refreshes the timeline; MediaPeriodQueue.updateQueuedPeriods then sees
the playing period's duration change, drops every period after it and
re-enqueues the next one, so the next track is opened from byte 0 again.
After a seek near the end that repeats a few times.

Route streamed bytes through a bounded SimpleCache (DefaultDataSource ->
CacheDataSource -> KnownLengthDataSource -> OkHttp). A re-opened next
period is served from the bytes the prefetch already committed, so the
redundant network fetches stop without changing playback length or
gapless padding. DefaultDataSource still handles file:/content:, so
downloads bypass the cache.

The cache is a process-scoped 64 MiB LRU in cacheDir/stream-cache; a
construction failure falls back to no cache so playback still starts.
This commit is contained in:
austinried
2026-10-09 13:36:55 +00:00
parent 0cef3657bc
commit e806eaef89
5 changed files with 105 additions and 4 deletions
+2
View File
@@ -244,7 +244,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource)
implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.database)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
@@ -12,8 +12,13 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
@@ -41,6 +46,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import java.io.File
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
@@ -91,6 +97,7 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>() val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>() library = GlobalContext.get().get<LibraryRepository>()
val cache = StreamCache.open(this)
val exoPlayer = val exoPlayer =
ExoPlayer ExoPlayer
.Builder(this) .Builder(this)
@@ -106,7 +113,11 @@ class PlaybackService : MediaSessionService() {
DefaultMediaSourceFactory(this) DefaultMediaSourceFactory(this)
.setEnableClippingInMediaPeriod(true) .setEnableClippingInMediaPeriod(true)
.setDataSourceFactory( .setDataSourceFactory(
mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value)), mediaDataSourceFactory(
this,
cache,
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
),
), ),
).build() ).build()
exoPlayer.addListener( exoPlayer.addListener(
@@ -216,8 +227,46 @@ class PlaybackService : MediaSessionService() {
} }
} }
// Process-scoped so it is never released while a loader thread is still committing to it, and so
// a recreated service does not try to lock the directory a second time.
@OptIn(UnstableApi::class)
private object StreamCache {
@Volatile
private var instance: SimpleCache? = null
fun open(context: Context): SimpleCache? =
instance ?: synchronized(this) {
instance ?: runCatching {
SimpleCache(
File(context.cacheDir, STREAM_CACHE_DIR),
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
StandaloneDatabaseProvider(context),
)
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
.getOrNull()
?.also { instance = it }
}
private const val STREAM_CACHE_DIR = "stream-cache"
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
internal fun mediaDataSourceFactory( internal fun mediaDataSourceFactory(
context: Context, context: Context,
cache: Cache?,
upstream: DataSource.Factory, upstream: DataSource.Factory,
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream)) ): DataSource.Factory {
val known = KnownLengthDataSourceFactory(upstream)
val source: DataSource.Factory =
if (cache == null) {
known
} else {
CacheDataSource
.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(known)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
return DefaultDataSource.Factory(context, source)
}
@@ -2,7 +2,12 @@ package com.subtracks.playback
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.media3.common.C
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.nio.charset.StandardCharsets
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MediaDataSourceTest { class MediaDataSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>() private val context = ApplicationProvider.getApplicationContext<Context>()
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource() private fun dataSource(cache: SimpleCache? = null) =
mediaDataSourceFactory(context, cache, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
@Test @Test
fun aDownloadedFileIsReadThroughTheStreamingChain() { fun aDownloadedFileIsReadThroughTheStreamingChain() {
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
assertEquals(1_000_000L, length) assertEquals(1_000_000L, length)
} }
@Test
fun reopeningAStreamIsServedFromTheCache() {
val server = MockWebServer()
server.enqueue(MockResponse().setBody("0123456789"))
server.start()
val url = "http://${server.hostName}:${server.port}/rest/stream"
val cache =
SimpleCache(
File(context.cacheDir, "stream-cache-${System.nanoTime()}"),
NoOpCacheEvictor(),
StandaloneDatabaseProvider(context),
)
val first = readAll(dataSource(cache), url)
val second = readAll(dataSource(cache), url)
val requests = server.requestCount
server.shutdown()
cache.release()
assertEquals("0123456789", String(first, StandardCharsets.UTF_8))
assertEquals(first.toList(), second.toList())
assertEquals(1, requests)
}
private fun readAll(
dataSource: DataSource,
url: String,
): ByteArray {
dataSource.open(DataSpec(Uri.parse(url)))
val out = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (true) {
val read = dataSource.read(buffer, 0, buffer.size)
if (read == C.RESULT_END_OF_INPUT) break
out.write(buffer, 0, read)
}
dataSource.close()
return out.toByteArray()
}
} }
+1 -1
View File
@@ -88,7 +88,7 @@ Playback is a *context* plus a manual *up next* list. `queue_entries` is the con
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet. The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
+2
View File
@@ -55,7 +55,9 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose",
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" } androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
androidx-media3-database = { group = "androidx.media3", name = "media3-database", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }