keep transcoded streams seekable without cutting them short

Media3 treats a progressive stream as live, and ignores seeks, when neither the HTTP length nor the extractor duration is known. A transcoded response is chunked with no length and ffmpeg writes no MP3 duration header when streaming, so seeks stopped working once estimateContentLength was dropped. The server estimate is not a fix either: it under-counts (23% at mp3/24) and truncates the track.

Transcoded URLs now carry a declared upper-bound length in the fragment (never sent to the server). KnownLengthDataSource hands that length to Media3, keeping the stream seekable while never bounding reads below the real size. MetadataDurationPlayer reports the library duration to the session so the notification and UI show the true length rather than the upper bound.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 525c3fa0d4
commit 22feb2372d
8 changed files with 202 additions and 5 deletions
@@ -9,6 +9,8 @@ import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.declaredStreamLength
import com.subtracks.data.source.streamLengthSuffix
import com.subtracks.data.source.subsonic.SubsonicClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -82,7 +84,14 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = "${source.id}:$coverArt:$thumbnail")
}
fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString()
fun streamUri(
songId: String,
durationMs: Long? = null,
): String? {
val uri = active?.streamUri(songId)?.toString() ?: return null
val length = declaredStreamLength(durationMs, quality.value) ?: return uri
return uri + streamLengthSuffix(length)
}
suspend fun addSource(
name: String,
@@ -0,0 +1,27 @@
package com.subtracks.data.source
import com.subtracks.data.prefs.StreamQuality
internal const val STREAM_LENGTH_FRAGMENT = "streamLength"
private const val MIN_TRANSCODE_BPS = 64_000L
private const val UNCAPPED_BPS = 2_000_000L
private const val OVERHEAD_BYTES = 256L * 1024L
fun declaredStreamLength(
durationMs: Long?,
quality: StreamQuality,
): Long? {
if (durationMs == null || durationMs <= 0) return null
if (quality.maxBitrate <= 0 && quality.format.isNullOrEmpty()) return null
val bps = if (quality.maxBitrate > 0) maxOf(quality.maxBitrate * 1000L, MIN_TRANSCODE_BPS) else UNCAPPED_BPS
return durationMs / 1000 * bps / 8 * 5 / 4 + OVERHEAD_BYTES
}
internal fun streamLengthSuffix(length: Long): String = "#$STREAM_LENGTH_FRAGMENT=$length"
internal fun declaredLengthFromFragment(fragment: String?): Long? {
val marker = "$STREAM_LENGTH_FRAGMENT="
if (fragment == null || !fragment.startsWith(marker)) return null
return fragment.substring(marker.length).toLongOrNull()
}
@@ -0,0 +1,42 @@
package com.subtracks.playback
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import com.subtracks.data.source.declaredLengthFromFragment
@UnstableApi
internal class KnownLengthDataSource(
private val delegate: DataSource,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
val length = delegate.open(dataSpec)
if (length != C.LENGTH_UNSET.toLong()) return length
val declared = declaredLengthFromFragment(dataSpec.uri.fragment) ?: return length
return (declared - dataSpec.position).coerceAtLeast(0)
}
override fun read(
buffer: ByteArray,
offset: Int,
length: Int,
): Int = delegate.read(buffer, offset, length)
override fun getUri(): Uri? = delegate.uri
override fun getResponseHeaders(): Map<String, List<String>> = delegate.responseHeaders
override fun close() = delegate.close()
override fun addTransferListener(transferListener: TransferListener) = delegate.addTransferListener(transferListener)
}
@UnstableApi
internal class KnownLengthDataSourceFactory(
private val upstream: DataSource.Factory,
) : DataSource.Factory {
override fun createDataSource(): DataSource = KnownLengthDataSource(upstream.createDataSource())
}
@@ -133,6 +133,7 @@ class Media3PlayerHandle(
.setArtist(item.artist)
.setAlbumTitle(item.album)
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
.setDurationMs(item.durationMs)
.setIsBrowsable(false)
.setIsPlayable(true)
.setExtras(
@@ -144,7 +145,7 @@ class Media3PlayerHandle(
return MediaItem
.Builder()
.setMediaId(item.id)
.setUri(sourceRepository.streamUri(item.id))
.setUri(sourceRepository.streamUri(item.id, item.durationMs))
.setMediaMetadata(metadata)
.build()
}
@@ -7,6 +7,8 @@ import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
@@ -26,7 +28,7 @@ class PlaybackService : MediaSessionService() {
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
val player =
val exoPlayer =
ExoPlayer
.Builder(this)
.setAudioAttributes(
@@ -38,8 +40,11 @@ class PlaybackService : MediaSessionService() {
true,
).setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(
DefaultMediaSourceFactory(this).setDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
DefaultMediaSourceFactory(this).setDataSourceFactory(
KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
),
).build()
val player = MetadataDurationPlayer(exoPlayer)
val loader =
CoverArtBitmapLoader(
applicationContext,
@@ -98,3 +103,10 @@ class PlaybackService : MediaSessionService() {
super.onDestroy()
}
}
@OptIn(UnstableApi::class)
private class MetadataDurationPlayer(
player: Player,
) : ForwardingPlayer(player) {
override fun getDuration(): Long = currentMediaItem?.mediaMetadata?.durationMs?.takeIf { it > 0 } ?: super.getDuration()
}
@@ -0,0 +1,43 @@
package com.subtracks.data.source
import com.subtracks.data.prefs.StreamQuality
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class StreamLengthTest {
@Test
fun directPlayHasNoDeclaredLength() {
assertNull(declaredStreamLength(180_000, StreamQuality()))
}
@Test
fun missingDurationHasNoDeclaredLength() {
assertNull(declaredStreamLength(null, StreamQuality(96, "mp3")))
assertNull(declaredStreamLength(0, StreamQuality(96, "mp3")))
}
@Test
fun transcodedLengthIsAboveTheRequestedBitrate() {
val length = declaredStreamLength(180_000, StreamQuality(96, "mp3"))!!
assertTrue(length > 180 * 96_000 / 8)
}
@Test
fun aTinyBitrateStillClearsTheEncodersFloor() {
val length = declaredStreamLength(180_000, StreamQuality(24, "mp3"))!!
assertTrue(length >= 180 * 32_000 / 8)
}
@Test
fun theLengthSuffixRoundTripsThroughTheFragment() {
val uri = "http://host/rest/stream.view?id=s1" + streamLengthSuffix(1_234_567)
assertEquals(1_234_567L, declaredLengthFromFragment(uri.substringAfter('#')))
assertNull(declaredLengthFromFragment(null))
assertNull(declaredLengthFromFragment("somethingElse=1"))
}
}
@@ -0,0 +1,63 @@
package com.subtracks.playback
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.source.streamLengthSuffix
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KnownLengthDataSourceTest {
private class StubDataSource(
private val length: Long,
) : DataSource {
override fun open(dataSpec: DataSpec): Long = length
override fun read(
buffer: ByteArray,
offset: Int,
length: Int,
): Int = C.RESULT_END_OF_INPUT
override fun getUri(): Uri? = null
override fun close() = Unit
override fun addTransferListener(transferListener: TransferListener) = Unit
}
private fun transcodedUrl(length: Long) = Uri.parse("http://host/stream" + streamLengthSuffix(length))
@Test
fun declaresTheFragmentLengthWhenTheServerDoesNot() {
val dataSource = KnownLengthDataSource(StubDataSource(C.LENGTH_UNSET.toLong()))
assertEquals(1_000_000L, dataSource.open(DataSpec(transcodedUrl(1_000_000))))
}
@Test
fun subtractsThePositionFromTheDeclaredLength() {
val dataSource = KnownLengthDataSource(StubDataSource(C.LENGTH_UNSET.toLong()))
assertEquals(600_000L, dataSource.open(DataSpec(transcodedUrl(1_000_000), 400_000L, C.LENGTH_UNSET.toLong())))
}
@Test
fun keepsTheServerLengthWhenItHasOne() {
val dataSource = KnownLengthDataSource(StubDataSource(500_000))
assertEquals(500_000L, dataSource.open(DataSpec(transcodedUrl(1_000_000))))
}
@Test
fun passesUnknownLengthThroughWithoutAFragment() {
val dataSource = KnownLengthDataSource(StubDataSource(C.LENGTH_UNSET.toLong()))
assertEquals(C.LENGTH_UNSET.toLong(), dataSource.open(DataSpec(Uri.parse("http://host/stream"))))
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
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).
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 `MetadataDurationPlayer` reports the library duration to the session so the notification and UI stay correct.
The window is also the queue the media notification and Android Auto can browse — 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. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.