diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 8b9ad78d..9d03617a 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt b/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt new file mode 100644 index 00000000..a58bcf06 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/source/StreamLength.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/subtracks/playback/KnownLengthDataSource.kt b/app/src/main/kotlin/com/subtracks/playback/KnownLengthDataSource.kt new file mode 100644 index 00000000..6b574b82 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/KnownLengthDataSource.kt @@ -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> = 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()) +} diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index c8e98b1f..65e96280 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -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() } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 1a80e403..fdf5b8d7 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -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() +} diff --git a/app/src/test/kotlin/com/subtracks/data/source/StreamLengthTest.kt b/app/src/test/kotlin/com/subtracks/data/source/StreamLengthTest.kt new file mode 100644 index 00000000..f762a8a9 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/source/StreamLengthTest.kt @@ -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")) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/KnownLengthDataSourceTest.kt b/app/src/test/kotlin/com/subtracks/playback/KnownLengthDataSourceTest.kt new file mode 100644 index 00000000..50774500 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/KnownLengthDataSourceTest.kt @@ -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")))) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index b4763e74..8f0166e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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.