From ffe4907d1db620140f4e4626a2303801ec5fea00 Mon Sep 17 00:00:00 2001 From: Stavros Korokithakis Date: Fri, 21 Aug 2026 11:59:58 +0300 Subject: [PATCH 1/4] Fix dead skip-unavailable-track logic The skip-unavailable-track handling in PlaybackNavigationService never ran. Two reasons: 1. bookToChapterMediaItems called setTag(book) without setUri. Media3 only builds a LocalConfiguration, which holds the tag, when a uri is present, so the tag was discarded at construction and the DetailedItem guard was always null. 2. The player reports the MediaItem of the MediaSource that LissenMediaSourceFactory returns, not the one it was given, since getMediaItemAt reads Timeline.Window.mediaItem. The factory forwarded only mediaMetadata, so mediaId and the FILE_SEGMENTS extras were lost and isTrackAvailable returned false for every track. Carry the chapter item's identity onto the sources the factory builds, and drop the redundant tag guard along with the no-op setTag. Both parts are needed together: restoring only the tag would let the guard pass while isTrackAvailable still failed, pausing playback on every chapter transition. --- .../playback/LissenMediaSourceFactoryTest.kt | 81 +++++++++++++------ .../service/LissenMediaSourceFactory.kt | 39 ++++----- .../service/PlaybackNavigationService.kt | 16 ++-- .../playback/service/PlaybackService.kt | 3 +- 4 files changed, 85 insertions(+), 54 deletions(-) diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt index a2ccebbf1..4a1a75ae4 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt @@ -5,12 +5,15 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every import io.mockk.mockk +import io.mockk.slot import org.grakovne.lissen.content.ExternalCoverProvider import org.grakovne.lissen.playback.service.FileClip import org.grakovne.lissen.playback.service.LissenMediaSourceFactory import org.grakovne.lissen.playback.service.PlaybackService.Companion.CHAPTER_START_MS import org.grakovne.lissen.playback.service.PlaybackService.Companion.FILE_SEGMENTS +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test @@ -29,30 +32,60 @@ class LissenMediaSourceFactoryTest { @Test fun no_exception_thrown_if_no_files() { - val mediaSource = - lissenMediaSourceFactory.createMediaSource( - MediaItem - .Builder() - .setMediaId(LissenMediaSourceFactory.MediaId("book-id", 5).toString()) - .setRequestMetadata( - MediaItem.RequestMetadata - .Builder() - .setExtras(bundleOf(FILE_SEGMENTS to arrayListOf())) - .build(), - ).setMediaMetadata( - MediaMetadata - .Builder() - .setAlbumTitle("title") - .setTitle("chapter") - .setArtist("book") - .setIsBrowsable(false) - .setIsPlayable(true) - .setArtworkUri(ExternalCoverProvider.bookCoverUri("book-id")) - .setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK_CHAPTER) - .setExtras(bundleOf(CHAPTER_START_MS to (500 * 1000).toLong())) - .build(), - ).build(), - ) + val mediaSource = lissenMediaSourceFactory.createMediaSource(chapterMediaItem(arrayListOf())) assertNotNull(mediaSource) } + + @Test + fun media_id_and_request_metadata_preserved_for_single_segment_chapter() { + val capturedItem = slot() + every { mediaSourceFactory.createMediaSource(capture(capturedItem)) } returns mockk(relaxed = true) + + val mediaItem = chapterMediaItem(arrayListOf(FileClip("file-1", 0.0, 30.0))) + lissenMediaSourceFactory.createMediaSource(mediaItem) + + assertEquals(mediaItem.mediaId, capturedItem.captured.mediaId) + assertEquals(mediaItem.requestMetadata, capturedItem.captured.requestMetadata) + assertEquals(mediaItem.mediaMetadata, capturedItem.captured.mediaMetadata) + } + + @Test + fun media_id_and_request_metadata_preserved_for_multi_segment_chapter() { + val mediaItem = + chapterMediaItem( + arrayListOf( + FileClip("file-1", 0.0, 30.0), + FileClip("file-2", 30.0, 60.0), + ), + ) + + val reportedItem = lissenMediaSourceFactory.createMediaSource(mediaItem).mediaItem + + assertEquals(mediaItem.mediaId, reportedItem.mediaId) + assertEquals(mediaItem.requestMetadata, reportedItem.requestMetadata) + assertEquals(mediaItem.mediaMetadata, reportedItem.mediaMetadata) + } + + private fun chapterMediaItem(segments: ArrayList): MediaItem = + MediaItem + .Builder() + .setMediaId(LissenMediaSourceFactory.MediaId("book-id", 5).toString()) + .setRequestMetadata( + MediaItem.RequestMetadata + .Builder() + .setExtras(bundleOf(FILE_SEGMENTS to segments)) + .build(), + ).setMediaMetadata( + MediaMetadata + .Builder() + .setAlbumTitle("title") + .setTitle("chapter") + .setArtist("book") + .setIsBrowsable(false) + .setIsPlayable(true) + .setArtworkUri(ExternalCoverProvider.bookCoverUri("book-id")) + .setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK_CHAPTER) + .setExtras(bundleOf(CHAPTER_START_MS to (500 * 1000).toLong())) + .build(), + ).build() } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt index 86f293120..c4f190a9c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt @@ -3,7 +3,6 @@ package org.grakovne.lissen.playback.service import android.os.Parcelable import androidx.core.os.BundleCompat import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.source.ClippingMediaSource @@ -59,25 +58,33 @@ class LissenMediaSourceFactory( override fun getSupportedTypes(): IntArray = mediaSourceFactory.supportedTypes + /** + * The player reports the MediaItem of the MediaSource we return here, not the one it was given: + * both getCurrentMediaItem() and getMediaItemAt() read Timeline.Window.mediaItem, and that comes + * from the created source. So every source we build must carry the chapter item's identity + * (mediaId, requestMetadata with FILE_SEGMENTS, mediaMetadata with CHAPTER_START_MS) or it becomes + * invisible to PlaybackNavigationService and PlaybackSynchronizationService. Do not drop it. + */ override fun createMediaSource(mediaItem: MediaItem): MediaSource { fun FileClip.toMediaSource( bookId: String, - metadata: MediaMetadata? = null, - ): MediaSource = - mediaSourceFactory - .createMediaSource( - MediaItem - .Builder() - .setUri(toLissenUri(bookId, fileId)) - .apply { metadata?.let { setMediaMetadata(it) } } - .build(), - ).let { + template: MediaItem? = null, + ): MediaSource { + val innerItem = + (template?.buildUpon() ?: MediaItem.Builder()) + .setUri(toLissenUri(bookId, fileId)) + .build() + + return mediaSourceFactory + .createMediaSource(innerItem) + .let { ClippingMediaSource .Builder(it) .setStartPositionUs((clipStart * 1_000_000).toLong()) .setEndPositionUs((clipEnd * 1_000_000).toLong()) .build() } + } return MediaId.fromString(mediaItem.mediaId)?.let { (bookId, chapterId) -> mediaItem.requestMetadata.extras?.let { extras -> @@ -88,7 +95,7 @@ class LissenMediaSourceFactory( } 1 -> { - segments.first().toMediaSource(bookId, mediaItem.mediaMetadata) + segments.first().toMediaSource(bookId, mediaItem) } else -> { @@ -98,12 +105,8 @@ class LissenMediaSourceFactory( segments.forEach { add(it.toMediaSource(bookId), ((it.clipEnd - it.clipStart) * 1000).toLong()) } - }.setMediaItem( - MediaItem - .Builder() - .setMediaMetadata(mediaItem.mediaMetadata) - .build(), - ).build() + }.setMediaItem(mediaItem) + .build() } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt index dfc82baf5..8a83a0a74 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackNavigationService.kt @@ -7,7 +7,6 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import org.grakovne.lissen.common.RunningComponent import org.grakovne.lissen.content.LissenMediaProvider -import org.grakovne.lissen.domain.DetailedItem import org.grakovne.lissen.persistence.preferences.PlaybackPreferences import javax.inject.Inject import javax.inject.Singleton @@ -36,6 +35,12 @@ class PlaybackNavigationService } } + /** + * Skips chapters whose files are missing, which happens in offline mode when a book is + * only partly downloaded. Known limitation: this only covers transitions between items, + * so an unavailable first chapter is not skipped and still fails on load. Setting the + * playlist raises onTimelineChanged, not a discontinuity, so we never see it here. + */ override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, @@ -43,15 +48,6 @@ class PlaybackNavigationService ) { val previousIndex = oldPosition.mediaItemIndex val currentIndex = newPosition.mediaItemIndex - val currentItem = - exoPlayer - .currentMediaItem - ?.localConfiguration - ?.tag as? DetailedItem - - if (null == currentItem) { - return - } if (isTrackAvailable(exoPlayer.currentMediaItemIndex)) { return diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt index 623a4bd96..1d82e656e 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -260,8 +260,7 @@ class PlaybackService : MediaLibraryService() { .setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK_CHAPTER) .setExtras(Bundle().apply { putLong(CHAPTER_START_MS, (chapter.start * 1000).toLong()) }) .build(), - ).setTag(book) - .build() + ).build() } return MediaItemsWithStartPosition(chapterMediaItems, chapterIndex, (chapterOffset * 1000).toLong()) } From 259ab7b2ede6f31bc7f0f9f73a94e5f916578580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Goli=C5=84ski?= Date: Mon, 24 Aug 2026 16:59:41 +0200 Subject: [PATCH 2/4] Make DefaultMediaSourceFactory an implementation detail of LissenMediaSourceFactory --- .../playback/LissenMediaSourceFactoryTest.kt | 22 ++++++++----------- .../grakovne/lissen/playback/MediaModule.kt | 18 +++++++-------- .../service/LissenMediaSourceFactory.kt | 5 ++++- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt index 4a1a75ae4..c91e8962d 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt @@ -3,13 +3,11 @@ package org.grakovne.lissen.playback import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.mockk.every import io.mockk.mockk -import io.mockk.slot import org.grakovne.lissen.content.ExternalCoverProvider import org.grakovne.lissen.playback.service.FileClip +import org.grakovne.lissen.playback.service.LissenDataSourceFactory import org.grakovne.lissen.playback.service.LissenMediaSourceFactory import org.grakovne.lissen.playback.service.PlaybackService.Companion.CHAPTER_START_MS import org.grakovne.lissen.playback.service.PlaybackService.Companion.FILE_SEGMENTS @@ -21,13 +19,14 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LissenMediaSourceFactoryTest { - private lateinit var mediaSourceFactory: DefaultMediaSourceFactory private lateinit var lissenMediaSourceFactory: LissenMediaSourceFactory + private lateinit var lissenDataSourceFactory: LissenDataSourceFactory + @Before fun setUp() { - mediaSourceFactory = mockk(relaxed = true) - lissenMediaSourceFactory = LissenMediaSourceFactory(mediaSourceFactory) + lissenDataSourceFactory = mockk(relaxed = true) + lissenMediaSourceFactory = LissenMediaSourceFactory(lissenDataSourceFactory) } @Test @@ -38,15 +37,12 @@ class LissenMediaSourceFactoryTest { @Test fun media_id_and_request_metadata_preserved_for_single_segment_chapter() { - val capturedItem = slot() - every { mediaSourceFactory.createMediaSource(capture(capturedItem)) } returns mockk(relaxed = true) - val mediaItem = chapterMediaItem(arrayListOf(FileClip("file-1", 0.0, 30.0))) - lissenMediaSourceFactory.createMediaSource(mediaItem) + val reportedItem = lissenMediaSourceFactory.createMediaSource(mediaItem).mediaItem - assertEquals(mediaItem.mediaId, capturedItem.captured.mediaId) - assertEquals(mediaItem.requestMetadata, capturedItem.captured.requestMetadata) - assertEquals(mediaItem.mediaMetadata, capturedItem.captured.mediaMetadata) + assertEquals(mediaItem.mediaId, reportedItem.mediaId) + assertEquals(mediaItem.requestMetadata, reportedItem.requestMetadata) + assertEquals(mediaItem.mediaMetadata, reportedItem.mediaMetadata) } @Test diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt index 29f6ba02e..7d14acc1b 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt @@ -92,16 +92,14 @@ object MediaModule { ).setRenderersFactory(renderersFactory) .setMediaSourceFactory( LissenMediaSourceFactory( - mediaSourceFactory = - DefaultMediaSourceFactory( - LissenDataSourceFactory( - baseContext = context, - mediaCache = mediaCache, - requestHeadersProvider = requestHeadersProvider, - session = sessionPreferences, - connection = connectionPreferences, - mediaProvider = mediaProvider, - ), + dataSourceFactory = + LissenDataSourceFactory( + baseContext = context, + mediaCache = mediaCache, + requestHeadersProvider = requestHeadersProvider, + session = sessionPreferences, + connection = connectionPreferences, + mediaProvider = mediaProvider, ), ), ).build() diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt index c4f190a9c..1e73fdabb 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.core.os.BundleCompat import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 @@ -23,8 +24,10 @@ data class FileClip( @UnstableApi class LissenMediaSourceFactory( - private val mediaSourceFactory: DefaultMediaSourceFactory, + dataSourceFactory: DataSource.Factory, ) : MediaSource.Factory { + private val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) + data class MediaId( val bookId: String, val chapterId: Int, From 4e564eb5fabcaea7b6d3029742e5bd4ef3976a08 Mon Sep 17 00:00:00 2001 From: Stavros Korokithakis Date: Mon, 24 Aug 2026 18:25:10 +0300 Subject: [PATCH 3/4] Assert media source extras --- .../playback/LissenMediaSourceFactoryTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt index c91e8962d..c8898da3b 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt @@ -1,5 +1,6 @@ package org.grakovne.lissen.playback +import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata @@ -43,6 +44,13 @@ class LissenMediaSourceFactoryTest { assertEquals(mediaItem.mediaId, reportedItem.mediaId) assertEquals(mediaItem.requestMetadata, reportedItem.requestMetadata) assertEquals(mediaItem.mediaMetadata, reportedItem.mediaMetadata) + assertEquals(500_000L, reportedItem.mediaMetadata.extras?.getLong(CHAPTER_START_MS)) + val reportedSegments = + reportedItem.requestMetadata.extras?.let { + BundleCompat.getParcelableArrayList(it, FILE_SEGMENTS, FileClip::class.java) + } + assertNotNull(reportedSegments) + assertEquals(1, reportedSegments!!.size) } @Test @@ -60,6 +68,13 @@ class LissenMediaSourceFactoryTest { assertEquals(mediaItem.mediaId, reportedItem.mediaId) assertEquals(mediaItem.requestMetadata, reportedItem.requestMetadata) assertEquals(mediaItem.mediaMetadata, reportedItem.mediaMetadata) + assertEquals(500_000L, reportedItem.mediaMetadata.extras?.getLong(CHAPTER_START_MS)) + val reportedSegments = + reportedItem.requestMetadata.extras?.let { + BundleCompat.getParcelableArrayList(it, FILE_SEGMENTS, FileClip::class.java) + } + assertNotNull(reportedSegments) + assertEquals(2, reportedSegments!!.size) } private fun chapterMediaItem(segments: ArrayList): MediaItem = From 5068b5830a7dd9efd8ea0e70f12dd416c1fc3f8a Mon Sep 17 00:00:00 2001 From: Stavros Korokithakis Date: Mon, 24 Aug 2026 19:04:56 +0300 Subject: [PATCH 4/4] Record why the extras assertions are needed --- .../lissen/playback/LissenMediaSourceFactoryTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt index c8898da3b..c07a90aec 100644 --- a/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt +++ b/app/src/androidTest/kotlin/org/grakovne/lissen/playback/LissenMediaSourceFactoryTest.kt @@ -19,6 +19,13 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) +// The factory is given a DataSource.Factory rather than a DefaultMediaSourceFactory so that these +// tests assert on the MediaSource the factory returns, which is what BasePlayer.getCurrentMediaItem +// reads via the timeline window. Mocking the inner factory would only prove what we passed into it. +// +// The extras assertions are not redundant with the enclosing metadata assertions: in Media3 1.11.0, +// MediaMetadata.equals and RequestMetadata.equals only check whether extras is null, never its +// contents. FILE_SEGMENTS and CHAPTER_START_MS have to be checked by hand. class LissenMediaSourceFactoryTest { private lateinit var lissenMediaSourceFactory: LissenMediaSourceFactory