clip transcoded tracks to their real duration

The declared upper-bound length stopped the stall where Media3 saw no seek map duration, but it also made the track look longer than the audio: the player reached the last sample still short of the declared end and waited for data that never came, so it never advanced. Clip each transcoded item to the library duration, which Media3 applies through ClippingMediaSource, so the timeline ends where the audio ends and the next track advances. The declared length still makes the stream on-demand (seekable) and never bounds reads below the real size.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 22feb2372d
commit ebaa486126
4 changed files with 26 additions and 12 deletions
@@ -38,7 +38,9 @@ data class ListQuery(
data class StreamQuality( data class StreamQuality(
val maxBitrate: Int = 0, val maxBitrate: Int = 0,
val format: String? = null, val format: String? = null,
) ) {
val transcodes: Boolean get() = maxBitrate > 0 || !format.isNullOrEmpty()
}
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs") private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
@@ -13,7 +13,7 @@ fun declaredStreamLength(
quality: StreamQuality, quality: StreamQuality,
): Long? { ): Long? {
if (durationMs == null || durationMs <= 0) return null if (durationMs == null || durationMs <= 0) return null
if (quality.maxBitrate <= 0 && quality.format.isNullOrEmpty()) return null if (!quality.transcodes) return null
val bps = if (quality.maxBitrate > 0) maxOf(quality.maxBitrate * 1000L, MIN_TRANSCODE_BPS) else UNCAPPED_BPS 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 return durationMs / 1000 * bps / 8 * 5 / 4 + OVERHEAD_BYTES
} }
@@ -4,6 +4,7 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.ClippingConfiguration
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
@@ -142,12 +143,20 @@ class Media3PlayerHandle(
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
}, },
).build() ).build()
return MediaItem val builder =
MediaItem
.Builder() .Builder()
.setMediaId(item.id) .setMediaId(item.id)
.setUri(sourceRepository.streamUri(item.id, item.durationMs)) .setUri(sourceRepository.streamUri(item.id, item.durationMs))
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.build() if (sourceRepository.quality.value.transcodes) {
item.durationMs?.takeIf { it > 0 }?.let { duration ->
builder.setClippingConfiguration(
ClippingConfiguration.Builder().setEndPositionMs(duration).build(),
)
}
}
return builder.build()
} }
private fun toQueueItem(mediaItem: MediaItem): QueueItem = private fun toQueueItem(mediaItem: MediaItem): QueueItem =
@@ -9,6 +9,7 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@@ -25,7 +26,7 @@ class PlaybackService : MediaSessionService() {
private var session: MediaSession? = null private var session: MediaSession? = null
private var artworkLoader: CoverArtBitmapLoader? = null private var artworkLoader: CoverArtBitmapLoader? = null
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class, ExperimentalApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val exoPlayer = val exoPlayer =
@@ -40,7 +41,9 @@ class PlaybackService : MediaSessionService() {
true, true,
).setHandleAudioBecomingNoisy(true) ).setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory( .setMediaSourceFactory(
DefaultMediaSourceFactory(this).setDataSourceFactory( DefaultMediaSourceFactory(this)
.setEnableClippingInMediaPeriod(true)
.setDataSourceFactory(
KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())), KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
), ),
).build() ).build()