From ebaa4861269ed07c9d89b8b883a0969df9f28518 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:51:46 +0000 Subject: [PATCH] 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. --- .../subtracks/data/prefs/UserPreferences.kt | 4 +++- .../com/subtracks/data/source/StreamLength.kt | 2 +- .../subtracks/playback/Media3PlayerHandle.kt | 21 +++++++++++++------ .../com/subtracks/playback/PlaybackService.kt | 11 ++++++---- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 0c612de9..df38dab1 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -38,7 +38,9 @@ data class ListQuery( data class StreamQuality( val maxBitrate: Int = 0, val format: String? = null, -) +) { + val transcodes: Boolean get() = maxBitrate > 0 || !format.isNullOrEmpty() +} private val Context.preferences: DataStore by preferencesDataStore("user_prefs") diff --git a/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt b/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt index a58bcf06..67f3b236 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt @@ -13,7 +13,7 @@ fun declaredStreamLength( quality: StreamQuality, ): Long? { 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 return durationMs / 1000 * bps / 8 * 5 / 4 + OVERHEAD_BYTES } diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 65e96280..30e66feb 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.util.Log import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.ClippingConfiguration import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -142,12 +143,20 @@ class Media3PlayerHandle( item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } }, ).build() - return MediaItem - .Builder() - .setMediaId(item.id) - .setUri(sourceRepository.streamUri(item.id, item.durationMs)) - .setMediaMetadata(metadata) - .build() + val builder = + MediaItem + .Builder() + .setMediaId(item.id) + .setUri(sourceRepository.streamUri(item.id, item.durationMs)) + .setMediaMetadata(metadata) + 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 = diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index fdf5b8d7..4149a5c0 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -9,6 +9,7 @@ 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.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer @@ -25,7 +26,7 @@ class PlaybackService : MediaSessionService() { private var session: MediaSession? = null private var artworkLoader: CoverArtBitmapLoader? = null - @OptIn(UnstableApi::class) + @OptIn(UnstableApi::class, ExperimentalApi::class) override fun onCreate() { super.onCreate() val exoPlayer = @@ -40,9 +41,11 @@ class PlaybackService : MediaSessionService() { true, ).setHandleAudioBecomingNoisy(true) .setMediaSourceFactory( - DefaultMediaSourceFactory(this).setDataSourceFactory( - KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())), - ), + DefaultMediaSourceFactory(this) + .setEnableClippingInMediaPeriod(true) + .setDataSourceFactory( + KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())), + ), ).build() val player = MetadataDurationPlayer(exoPlayer) val loader =