diff --git a/app/src/main/kotlin/com/subtracks/playback/KnownDurationPlayer.kt b/app/src/main/kotlin/com/subtracks/playback/KnownDurationPlayer.kt new file mode 100644 index 00000000..70ac5f03 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/KnownDurationPlayer.kt @@ -0,0 +1,30 @@ +package com.subtracks.playback + +import androidx.annotation.OptIn +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi + +/** + * Reports a track whose duration is known from its metadata as finite rather than live. + * + * Media3 marks a progressive stream live when the data source reports no length and the extractor + * no duration. The app declares a length only for a transcode of a track that has one in a + * length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for + * a transcode of a track whose library duration is missing, and for a direct play the server does + * not give a Content-Length. A live item makes the media session publish no playback position, so + * the notification loses its progress even though the item carries a real duration. The stream is + * still not seekable; this only restores the position the session reports. + */ +@OptIn(UnstableApi::class) +internal class KnownDurationPlayer( + player: Player, +) : ForwardingPlayer(player) { + override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive() + + private fun declaredDurationMs(): Long? = + currentMediaItem + ?.mediaMetadata + ?.durationMs + ?.takeIf { it > 0 } +} diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 4b5c8183..8ecdfcc8 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -130,6 +130,7 @@ class PlaybackService : MediaSessionService() { } }, ) + val player = KnownDurationPlayer(exoPlayer) val loader = CoverArtBitmapLoader( applicationContext, @@ -139,7 +140,7 @@ class PlaybackService : MediaSessionService() { artworkLoader = loader session = MediaSession - .Builder(this, exoPlayer) + .Builder(this, player) .setSessionActivity( PendingIntent.getActivity( this, diff --git a/app/src/test/kotlin/com/subtracks/playback/KnownDurationPlayerTest.kt b/app/src/test/kotlin/com/subtracks/playback/KnownDurationPlayerTest.kt new file mode 100644 index 00000000..65173124 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/KnownDurationPlayerTest.kt @@ -0,0 +1,83 @@ +package com.subtracks.playback + +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(UnstableApi::class) +@RunWith(AndroidJUnit4::class) +class KnownDurationPlayerTest { + private fun player( + declaredDurationMs: Long?, + live: Boolean = true, + ): Player = + object : SimpleBasePlayer(Looper.getMainLooper()) { + override fun getState(): State { + val metadata = + MediaMetadata + .Builder() + .setTitle("Track") + .setDurationMs(declaredDurationMs) + .build() + val item = + MediaItemData + .Builder("track") + .setMediaItem( + MediaItem + .Builder() + .setMediaId("s1") + .setMediaMetadata(metadata) + .build(), + ).setDurationUs(PLAYER_DURATION_US) + .setLiveConfiguration(if (live) MediaItem.LiveConfiguration.UNSET else null) + .build() + return State + .Builder() + .setPlaylist(listOf(item)) + .build() + } + } + + @Test + fun aLiveTrackWithADeclaredDurationIsReportedFinite() { + val player = player(declaredDurationMs = 100_000L) + + assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive()) + } + + @Test + fun aLiveTrackWithoutADeclaredDurationStaysLive() { + val player = player(declaredDurationMs = null) + + assertTrue(KnownDurationPlayer(player).isCurrentMediaItemLive()) + } + + @Test + fun aFiniteTrackWithADeclaredDurationStaysFinite() { + val player = player(declaredDurationMs = 100_000L, live = false) + + assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive()) + } + + @Test + fun theWrappedDurationIsThePlayersNotTheMetadata() { + val player = player(declaredDurationMs = 100_000L) + + assertEquals(PLAYER_DURATION_MS, KnownDurationPlayer(player).getDuration()) + } + + private companion object { + const val PLAYER_DURATION_MS = 200_000L + const val PLAYER_DURATION_US = PLAYER_DURATION_MS * 1000 + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 8f380f5b..ad537746 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,7 +88,7 @@ Playback is a *context* plus a manual *up next* list. `queue_entries` is the con 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). 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 each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download. +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 each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download. The window is also the queue the media notification browses — 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. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.