mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -12,8 +12,13 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.ExperimentalApi
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
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.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
@@ -41,6 +46,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.context.GlobalContext
|
||||
import java.io.File
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaSessionService() {
|
||||
@@ -91,6 +97,7 @@ class PlaybackService : MediaSessionService() {
|
||||
val sources = GlobalContext.get().get<SourceRepository>()
|
||||
sourceRepository = sources
|
||||
library = GlobalContext.get().get<LibraryRepository>()
|
||||
val cache = StreamCache.open(this)
|
||||
val exoPlayer =
|
||||
ExoPlayer
|
||||
.Builder(this)
|
||||
@@ -106,7 +113,11 @@ class PlaybackService : MediaSessionService() {
|
||||
DefaultMediaSourceFactory(this)
|
||||
.setEnableClippingInMediaPeriod(true)
|
||||
.setDataSourceFactory(
|
||||
mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value)),
|
||||
mediaDataSourceFactory(
|
||||
this,
|
||||
cache,
|
||||
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||
),
|
||||
),
|
||||
).build()
|
||||
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)
|
||||
internal fun mediaDataSourceFactory(
|
||||
context: Context,
|
||||
cache: Cache?,
|
||||
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.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.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaDataSourceTest {
|
||||
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
|
||||
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
||||
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user