From 013e5fa15d48ecf0976a67e19fc683a035be16ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Goli=C5=84ski?= Date: Thu, 26 Feb 2026 14:02:40 +0100 Subject: [PATCH] Return chapters as mediaItems in MediaLibrarySession (#350) * Put chapters as mediaItems into ExoPlayer When a book is being played, the ExoPlayer get now media items corresponding to chapters. Android Auto will show them to the user as chapters. They get resolved to actual file segments in a new MediaSourceFactory. * Other packages as in the Voice app * Make onSetMediaItems more robust * use the calculateChapterIndexAndPosition function * Introduce a higher order function to skip unnecessary list allocation --- app/build.gradle.kts | 1 + .../lissen/content/LissenMediaProvider.kt | 2 +- .../playback/MediaLibrarySessionProvider.kt | 102 ++++------ .../lissen/playback/MediaLibraryTree.kt | 7 +- .../grakovne/lissen/playback/MediaModule.kt | 24 ++- .../lissen/playback/MediaRepository.kt | 2 +- .../service/LissenMediaSourceFactory.kt | 102 ++++++++++ .../playback/service/PlaybackService.kt | 158 +++++++++++----- .../service/PlaybackSynchronizationService.kt | 39 ++-- .../service}/CalculateChapterPositionTest.kt | 22 ++- .../service/ResolveChapterToFilesTest.kt | 179 ++++++++++++++++++ gradle/libs.versions.toml | 3 +- 12 files changed, 473 insertions(+), 168 deletions(-) create mode 100644 app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt rename app/src/test/kotlin/{ => org/grakovne/lissen/playback/service}/CalculateChapterPositionTest.kt (92%) create mode 100644 app/src/test/kotlin/org/grakovne/lissen/playback/service/ResolveChapterToFilesTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c05a7fb6..3977c38c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("com.google.dagger.hilt.android") id("org.jmailen.kotlinter") version "5.4.2" id("com.google.devtools.ksp") + id("kotlin-parcelize") } kotlinter { diff --git a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt index a570632c..86552e59 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -97,7 +97,7 @@ class LissenMediaProvider detailedItem: DetailedItem, progress: PlaybackProgress, ): OperationResult { - Timber.d("Syncing Progress for $detailedItem. $progress") + Timber.d("Syncing Progress for ${detailedItem.id}. $progress") localCacheRepository.syncProgress(detailedItem, progress) diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionProvider.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionProvider.kt index 76753147..bf4bb554 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionProvider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibrarySessionProvider.kt @@ -3,7 +3,6 @@ package org.grakovne.lissen.playback import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.LruCache import android.view.KeyEvent @@ -11,9 +10,8 @@ import android.view.KeyEvent.KEYCODE_MEDIA_NEXT import android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS import androidx.annotation.OptIn import androidx.core.net.toUri +import androidx.media3.common.C import androidx.media3.common.MediaItem -import androidx.media3.common.MediaItem.SubtitleConfiguration -import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.CommandButton @@ -30,11 +28,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.future.future import org.grakovne.lissen.BuildConfig import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.SeekTimeOption import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences +import org.grakovne.lissen.playback.service.PlaybackService +import org.grakovne.lissen.playback.service.PlaybackSynchronizationService import org.grakovne.lissen.ui.activity.AppActivity import org.grakovne.lissen.util.asListenableFuture import timber.log.Timber @@ -52,12 +53,19 @@ class MediaLibrarySessionProvider private val lissenMediaProvider: LissenMediaProvider, private val exoPlayer: ExoPlayer, private val libraryTree: MediaLibraryTree, + private val playbackSynchronizationService: PlaybackSynchronizationService, ) { @OptIn(UnstableApi::class, DelicateCoroutinesApi::class) fun provideMediaLibrarySession(mediaLibraryService: MediaLibraryService): MediaLibraryService.MediaLibrarySession { val knownPackages = listOf( - "com.google.android.projection.gearhead", // Android Auto + // by https://github.com/PaulWoitaschek/Voice/blob/main/core/playback/src/main/kotlin/voice/core/playback/session/ImageFileProvider.kt + "com.android.systemui", + "com.google.android.autosimulator", + "com.google.android.carassistant", + "com.google.android.googlequicksearchbox", + "com.google.android.projection.gearhead", + "com.google.android.wearable.app", "androidx.media3.testapp.controller", // Media3 controller test app ) for (pkg in knownPackages) { @@ -173,41 +181,6 @@ class MediaLibrarySessionProvider return super.onCustomCommand(session, controller, customCommand, args) } - private fun buildMediaItem( - title: String, - mediaId: String, - isPlayable: Boolean, - isBrowsable: Boolean, - mediaType: @MediaMetadata.MediaType Int, - subtitleConfigurations: List = mutableListOf(), - album: String? = null, - artist: String? = null, - genre: String? = null, - sourceUri: Uri? = null, - imageUri: Uri? = null, - ): MediaItem { - val metadata = - MediaMetadata - .Builder() - .setAlbumTitle(album) - .setTitle(title) - .setArtist(artist) - .setGenre(genre) - .setIsBrowsable(isBrowsable) - .setIsPlayable(isPlayable) - .setArtworkUri(imageUri) - .setMediaType(mediaType) - .build() - - return MediaItem - .Builder() - .setMediaId(mediaId) - .setSubtitleConfigurations(subtitleConfigurations) - .setMediaMetadata(metadata) - .setUri(sourceUri) - .build() - } - override fun onGetLibraryRoot( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, @@ -235,35 +208,28 @@ class MediaLibrarySessionProvider mediaItems: List, startIndex: Int, startPositionMs: Long, - ): ListenableFuture { - if (mediaItems.size == 1 && mediaItems[0].mediaId.startsWith("[bookID]")) { - return futureScope - .future { - val bookId = mediaItems[0].mediaId.removePrefix("[bookID]") - val book = lissenMediaProvider.fetchBook(bookId) - - val files = - book - .map { - mediaRepository.prepareAndPlay(it) - it.files.map { file -> - buildMediaItem( - title = file.name, - mediaId = file.id, - isPlayable = true, - isBrowsable = false, - mediaType = MediaMetadata.MEDIA_TYPE_AUDIO_BOOK_CHAPTER, - sourceUri = - org.grakovne.lissen.playback.service - .apply(bookId, file.id), - ) - } - }.fold({ it }, { emptyList() }) - MediaItemsWithStartPosition(files, 0, 0) - }.asListenableFuture() - } - return super.onSetMediaItems(mediaSession, controller, mediaItems, startIndex, startPositionMs) - } + ): ListenableFuture = + mediaItems.singleOrNull()?.let { mediaItem -> + if (mediaItem.mediaId.startsWith("[bookID]") && startIndex == C.INDEX_UNSET && startPositionMs == C.TIME_UNSET) { + futureScope + .future { + val bookId = mediaItem.mediaId.removePrefix("[bookID]") + lissenMediaProvider + .fetchBook(bookId) + .foldAsync( + onSuccess = { + async { + playbackSynchronizationService.startPlaybackSynchronization(it) + } + PlaybackService.bookToChapterMediaItems(it) + }, + onFailure = { MediaItemsWithStartPosition(emptyList(), 0, 0) }, + ) + }.asListenableFuture() + } else { + null + } + } ?: super.onSetMediaItems(mediaSession, controller, mediaItems, startIndex, startPositionMs) var searchCache = LruCache>>(3) diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt index d2186c35..ba9ffd69 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaLibraryTree.kt @@ -69,7 +69,7 @@ class MediaLibraryTree artist = book.author, mediaId = "$BOOK_ID${book.id}", isPlayable = true, - isBrowsable = true, + isBrowsable = false, mediaType = MediaMetadata.MEDIA_TYPE_AUDIO_BOOK, imageUri = ExternalCoverProvider.coverUri(book.id), ) @@ -80,7 +80,7 @@ class MediaLibraryTree artist = book.author, mediaId = "$BOOK_ID${book.id}", isPlayable = true, - isBrowsable = true, + isBrowsable = false, mediaType = MediaMetadata.MEDIA_TYPE_AUDIO_BOOK, imageUri = ExternalCoverProvider.coverUri(book.id), ) @@ -91,7 +91,7 @@ class MediaLibraryTree artist = book.author, mediaId = "$BOOK_ID${book.id}", isPlayable = true, - isBrowsable = true, + isBrowsable = false, mediaType = MediaMetadata.MEDIA_TYPE_AUDIO_BOOK, imageUri = ExternalCoverProvider.coverUri(book.id), ) @@ -264,7 +264,6 @@ class MediaLibraryTree onFailure = { null }, ) - // TODO: return chapters, not a single book @OptIn(UnstableApi::class) fun getBook(bookId: String) = futureScope 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 8c90713a..f513f89a 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt @@ -25,6 +25,7 @@ import org.grakovne.lissen.channel.audiobookshelf.common.api.RequestHeadersProvi import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import org.grakovne.lissen.playback.service.LissenDataSourceFactory +import org.grakovne.lissen.playback.service.LissenMediaSourceFactory import timber.log.Timber import java.io.File import javax.inject.Singleton @@ -79,20 +80,23 @@ object MediaModule { .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) .build(), true, - ).setMediaSourceFactory( - DefaultMediaSourceFactory(context).setDataSourceFactory( - LissenDataSourceFactory( - baseContext = context, - mediaCache = mediaCache, - requestHeadersProvider = requestHeadersProvider, - sharedPreferences = sharedPreferences, - mediaProvider = mediaProvider, - ), + ).setRenderersFactory(renderersFactory) + .setMediaSourceFactory( + LissenMediaSourceFactory( + mediaSourceFactory = + DefaultMediaSourceFactory( + LissenDataSourceFactory( + baseContext = context, + mediaCache = mediaCache, + requestHeadersProvider = requestHeadersProvider, + sharedPreferences = sharedPreferences, + mediaProvider = mediaProvider, + ), + ), ), ).build() player.addAnalyticsListener(mediaCodecListener(context)) - return player } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt index a219cd53..712932c9 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/MediaRepository.kt @@ -464,7 +464,7 @@ class MediaRepository private fun updateProgress(detailedItem: DetailedItem): Deferred = CoroutineScope(Dispatchers.Main).async { val currentIndex = mediaController.currentMediaItemIndex - val accumulated = detailedItem.files.take(currentIndex).sumOf { it.duration } + val accumulated = detailedItem.chapters.take(currentIndex).sumOf { it.duration } val currentFilePosition = mediaController.currentPosition / 1000.0 _totalPosition.postValue(accumulated + currentFilePosition) 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 new file mode 100644 index 00000000..f98554a3 --- /dev/null +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSourceFactory.kt @@ -0,0 +1,102 @@ +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 +import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import kotlinx.parcelize.Parcelize +import org.grakovne.lissen.playback.service.PlaybackService.Companion.FILE_SEGMENTS +import timber.log.Timber + +@Parcelize +data class FileClip( + val fileId: String, + val clipStart: Double, + val clipEnd: Double, +) : Parcelable + +@UnstableApi +class LissenMediaSourceFactory( + private val mediaSourceFactory: DefaultMediaSourceFactory, +) : MediaSource.Factory { + data class MediaId( + val bookId: String, + val chapterId: Int, + ) { + override fun toString(): String = "chapter:$bookId:$chapterId" + + companion object { + private val regex = """chapter:([^/]+):(\d+)$""".toRegex() + + fun fromString(mediaIdStr: String): MediaId? = + regex.find(mediaIdStr)?.let { + it.destructured.let { (bookId, chapterIdStr) -> + MediaId( + bookId = bookId, + chapterId = chapterIdStr.toInt(), + ) + } + } + } + } + + override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { + mediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) + return this + } + + override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory { + mediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) + return this + } + + override fun getSupportedTypes(): IntArray = mediaSourceFactory.supportedTypes + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + fun FileClip.toMediaSource( + bookId: String, + metadata: MediaMetadata? = null, + ): MediaSource = + mediaSourceFactory + .createMediaSource( + MediaItem + .Builder() + .setUri(apply(bookId, fileId)) + .apply { metadata?.let { setMediaMetadata(it) } } + .build(), + ).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 -> + BundleCompat.getParcelableArrayList(extras, FILE_SEGMENTS, FileClip::class.java)?.let { segments -> + segments.singleOrNull()?.toMediaSource(bookId, mediaItem.mediaMetadata) + ?: ConcatenatingMediaSource2 + .Builder() + .apply { + segments.forEach { + add(it.toMediaSource(bookId), ((it.clipEnd - it.clipStart) * 1000).toLong()) + } + }.setMediaItem( + MediaItem + .Builder() + .setMediaMetadata(mediaItem.mediaMetadata) + .build(), + ).build() + } + } + } ?: mediaSourceFactory.createMediaSource(mediaItem) + } +} 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 b0f4ac41..ee575c5f 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 @@ -3,15 +3,16 @@ package org.grakovne.lissen.playback.service import android.content.Intent import androidx.annotation.OptIn import androidx.core.net.toUri +import androidx.core.os.bundleOf import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.cache.Cache import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope @@ -21,17 +22,17 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.grakovne.lissen.channel.audiobookshelf.common.api.RequestHeadersProvider +import org.grakovne.lissen.content.ExternalCoverProvider import org.grakovne.lissen.content.LissenMediaProvider import org.grakovne.lissen.lib.domain.BookFile import org.grakovne.lissen.lib.domain.DetailedItem -import org.grakovne.lissen.lib.domain.MediaProgress +import org.grakovne.lissen.lib.domain.PlayingChapter import org.grakovne.lissen.lib.domain.TimerOption import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import org.grakovne.lissen.playback.MediaLibrarySessionProvider import timber.log.Timber import javax.inject.Inject -@UnstableApi @AndroidEntryPoint class PlaybackService : MediaLibraryService() { @Inject @@ -59,6 +60,7 @@ class PlaybackService : MediaLibraryService() { lateinit var playbackTimer: PlaybackTimer @Inject + @UnstableApi lateinit var mediaCache: Cache private var session: MediaLibrarySession? = null @@ -125,7 +127,7 @@ class PlaybackService : MediaLibraryService() { val book = sharedPreferences.getPlayingBook() val position = intent.getDoubleExtra(POSITION, 0.0) - book?.let { seek(it.files, position) } + book?.let { seek(it.chapters, position) } return START_NOT_STICKY } @@ -163,47 +165,11 @@ class PlaybackService : MediaLibraryService() { withContext(Dispatchers.IO) { val prepareQueue = async { - val sourceFactory = - LissenDataSourceFactory( - baseContext = baseContext, - mediaCache = mediaCache, - requestHeadersProvider = requestHeadersProvider, - sharedPreferences = sharedPreferences, - mediaProvider = mediaProvider, - ) - - val playingItemCover = fetchCover(book) - - val playingQueue = - book - .files - .map { file -> - val mediaData = - MediaMetadata - .Builder() - .setTitle(file.name) - .setArtist(book.title) - .setArtworkUri(playingItemCover) - - val mediaItem = - MediaItem - .Builder() - .setMediaId(file.id) - .setUri(apply(book.id, file.id)) - .setTag(book) - .setMediaMetadata(mediaData.build()) - .build() - - ProgressiveMediaSource - .Factory(sourceFactory) - .createMediaSource(mediaItem) - } - + val itemsWithPosition = bookToChapterMediaItems(book) withContext(Dispatchers.Main) { - exoPlayer.setMediaSources(playingQueue) + exoPlayer.setMediaItems(itemsWithPosition.mediaItems) exoPlayer.prepare() - - setPlaybackProgress(book.files, book.progress) + exoPlayer.seekTo(itemsWithPosition.startIndex, itemsWithPosition.startPositionMs) } } @@ -255,7 +221,7 @@ class PlaybackService : MediaLibraryService() { } private fun seek( - items: List, + items: List, position: Double?, ) { if (items.isEmpty()) { @@ -293,11 +259,6 @@ class PlaybackService : MediaLibraryService() { } } - private fun setPlaybackProgress( - chapters: List, - progress: MediaProgress?, - ) = seek(chapters, progress?.currentTime) - companion object { const val ACTION_PLAY = "org.grakovne.lissen.player.service.PLAY" const val ACTION_PAUSE = "org.grakovne.lissen.player.service.PAUSE" @@ -314,5 +275,104 @@ class PlaybackService : MediaLibraryService() { const val TIMER_REMAINING = "org.grakovne.lissen.player.service.TIMER_REMAINING" const val PLAYBACK_READY = "org.grakovne.lissen.player.service.PLAYBACK_READY" const val POSITION = "org.grakovne.lissen.player.service.POSITION" + + const val FILE_SEGMENTS = "org.grakovne.lissen.player.service.FILE_SEGMENTS" + const val CHAPTER_START_MS = "org.grakovne.lissen.player.service.CHAPTER_START_MS" + + internal fun resolveChapterToFiles( + chapters: List, + files: List, + ): List> = resolveChapterToFiles(chapters, files) { index, chapter, resolvedFiles -> resolvedFiles } + + internal fun resolveChapterToFiles( + chapters: List, + files: List, + resolvedFilesConsumer: (Int, PlayingChapter, ArrayList) -> T, + ): List { + if (files.isEmpty() || chapters.isEmpty()) return emptyList() + + val result = ArrayList(chapters.size) + + val filesIterator = files.iterator() + var currentFile = filesIterator.next() + + var allocatedFilesEnd = 0.0 + val epsilon = 0.01 + + chapters.forEachIndexed { index, chapter -> + val chapterClips = ArrayList(1) // We usually don't expect more than one clip. + var outstandingPartStart = chapter.start + + while (outstandingPartStart < chapter.end - epsilon) { + val currentFileEnd = allocatedFilesEnd + currentFile.duration + val overlapEnd = minOf(chapter.end, currentFileEnd) + + // Add to the clips only if long enough + if (epsilon < overlapEnd - outstandingPartStart) { + chapterClips.add( + FileClip( + fileId = currentFile.id, + clipStart = outstandingPartStart - allocatedFilesEnd, + clipEnd = overlapEnd - allocatedFilesEnd, + ), + ) + } + + if (currentFileEnd < chapter.end && filesIterator.hasNext()) { + allocatedFilesEnd += currentFile.duration + currentFile = filesIterator.next() + } else { + break + } + + outstandingPartStart = overlapEnd + } + result.add(resolvedFilesConsumer(index, chapter, chapterClips)) + } + + return result + } + + @UnstableApi + fun bookToChapterMediaItems(book: DetailedItem): MediaItemsWithStartPosition { + var (chapterIndex, chapterOffset) = + book.progress?.currentTime?.let { + calculateChapterIndexAndPosition(book, it) + } ?: ChapterPosition(0, 0.0) + if (chapterIndex < 0 || (chapterIndex == book.chapters.lastIndex && (book.chapters.last().end - 5) < chapterOffset)) { + chapterIndex = 0 + chapterOffset = 0.0 + } + + val chapterMediaItems = + resolveChapterToFiles(chapters = book.chapters, files = book.files) { index, chapter, resolvedFiles -> + MediaItem + .Builder() + .setMediaId(LissenMediaSourceFactory.MediaId(book.id, index).toString()) + .setRequestMetadata( + MediaItem.RequestMetadata + .Builder() + .setExtras(bundleOf(FILE_SEGMENTS to resolvedFiles)) + .build(), + ).setMediaMetadata( + MediaMetadata + .Builder() + .setAlbumTitle(book.title) + .setTitle(chapter.title) + .setArtist(book.title) // looks nicer this way + .setIsBrowsable(false) + .setIsPlayable(true) + .setArtworkUri(ExternalCoverProvider.coverUri(book.id)) + .setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK_CHAPTER) + .setExtras( + bundleOf( + CHAPTER_START_MS to (chapter.start * 1000).toLong(), + ), + ).build(), + ).setTag(book) + .build() + } + return MediaItemsWithStartPosition(chapterMediaItems, chapterIndex, (chapterOffset * 1000).toLong()) + } } } diff --git a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt index 76c4f629..31aa6f79 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt @@ -16,6 +16,7 @@ import org.grakovne.lissen.lib.domain.PlaybackProgress import org.grakovne.lissen.lib.domain.PlaybackSession import org.grakovne.lissen.lib.domain.PlaybackSessionSource import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences +import org.grakovne.lissen.playback.service.PlaybackService.Companion.CHAPTER_START_MS import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -90,8 +91,7 @@ class PlaybackSynchronizationService } private fun runSync() { - val elapsedMs = exoPlayer.currentPosition - val overallProgress = getProgress(elapsedMs) ?: return + val overallProgress = getProgress(exoPlayer) ?: return val currentItem = currentItem ?: return Timber.d("Trying to sync $overallProgress for ${currentItem.id}") @@ -165,29 +165,18 @@ class PlaybackSynchronizationService ) } - private fun getProgress(currentElapsedMs: Long): PlaybackProgress? { - val currentItem = - exoPlayer - .currentMediaItem - ?.localConfiguration - ?.tag as? DetailedItem - ?: return null - - val currentIndex = exoPlayer.currentMediaItemIndex - - val previousDuration = - currentItem.files - .take(currentIndex) - .sumOf { it.duration * 1000 } - - val currentTotalTime = (previousDuration + currentElapsedMs) / 1000.0 - val currentChapterTime = calculateChapterPosition(currentItem, currentTotalTime) - - return PlaybackProgress( - currentTotalTime = currentTotalTime, - currentChapterTime = currentChapterTime, - ) - } + private fun getProgress(exoPlayer: ExoPlayer): PlaybackProgress? = + exoPlayer.currentMediaItem + ?.mediaMetadata + ?.extras + ?.getLong(CHAPTER_START_MS, -1) + ?.takeIf { it >= 0 } + ?.let { currentChapterOffsetMs -> + PlaybackProgress( + currentTotalTime = (currentChapterOffsetMs + exoPlayer.currentPosition) / 1000.0, + currentChapterTime = exoPlayer.currentPosition / 1000.0, + ) + } companion object { private const val SYNC_INTERVAL_LONG = 30_000L diff --git a/app/src/test/kotlin/CalculateChapterPositionTest.kt b/app/src/test/kotlin/org/grakovne/lissen/playback/service/CalculateChapterPositionTest.kt similarity index 92% rename from app/src/test/kotlin/CalculateChapterPositionTest.kt rename to app/src/test/kotlin/org/grakovne/lissen/playback/service/CalculateChapterPositionTest.kt index 4a977a52..7ce435ee 100644 --- a/app/src/test/kotlin/CalculateChapterPositionTest.kt +++ b/app/src/test/kotlin/org/grakovne/lissen/playback/service/CalculateChapterPositionTest.kt @@ -1,7 +1,8 @@ +package org.grakovne.lissen.playback.service + import org.grakovne.lissen.lib.domain.DetailedItem import org.grakovne.lissen.lib.domain.PlayingChapter -import org.grakovne.lissen.playback.service.calculateChapterIndexAndPosition -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -53,8 +54,13 @@ class CalculateChapterPositionTest { tolerance: Double = 0.001, ) { val (index, position) = calculateChapterIndexAndPosition(book, overallPosition) - assertEquals(expectedIndex, index, "Wrong chapter index for pos=$overallPosition") - assertEquals(expectedPosition, position, tolerance, "Wrong chapter position for pos=$overallPosition") + Assertions.assertEquals(expectedIndex, index, "Wrong chapter index for pos=$overallPosition") + Assertions.assertEquals( + expectedPosition, + position, + tolerance, + "Wrong chapter position for pos=$overallPosition", + ) } @Nested @@ -200,8 +206,8 @@ class CalculateChapterPositionTest { fun `very small chapter durations`() { val book = createBook(0.05, 0.05, 100.0) val (newIndex, newPosition) = calculateChapterIndexAndPosition(book, 0.0) - assertEquals(2, newIndex) - assertEquals(-0.1, newPosition, 0.001) + Assertions.assertEquals(2, newIndex) + Assertions.assertEquals(-0.1, newPosition, 0.001) } @Test @@ -223,8 +229,8 @@ class CalculateChapterPositionTest { // Just verify consistency val pos = -5.0 val (index, position) = calculateChapterIndexAndPosition(book, pos) - assertEquals(0, index) - assertEquals(-5.0, position, 0.001) + Assertions.assertEquals(0, index) + Assertions.assertEquals(-5.0, position, 0.001) } } } diff --git a/app/src/test/kotlin/org/grakovne/lissen/playback/service/ResolveChapterToFilesTest.kt b/app/src/test/kotlin/org/grakovne/lissen/playback/service/ResolveChapterToFilesTest.kt new file mode 100644 index 00000000..e8b3b11c --- /dev/null +++ b/app/src/test/kotlin/org/grakovne/lissen/playback/service/ResolveChapterToFilesTest.kt @@ -0,0 +1,179 @@ +package org.grakovne.lissen.playback.service + +import org.grakovne.lissen.lib.domain.BookChapterState +import org.grakovne.lissen.lib.domain.BookFile +import org.grakovne.lissen.lib.domain.PlayingChapter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ResolveChapterToFilesTest { + data class Clip( + val fileId: String, + val clipStart: Number, + val clipEnd: Number, + ) + + @Test + fun `1-to-1 mapping`() = + assertFileResolution( + chapterDurations = listOf(10, 15), + fileDurations = listOf(10, 15), + expected = + listOf( + listOf(Clip("F0", 0, 10)), + listOf(Clip("F1", 0, 15)), + ), + ) + + @Test + fun `one chapter spanning multiple files`() = + assertFileResolution( + chapterDurations = listOf(30), + fileDurations = listOf(10, 10, 10), + expected = + listOf( + listOf( + Clip("F0", 0, 10), + Clip("F1", 0, 10), + Clip("F2", 0, 10), + ), + ), + ) + + @Test + fun `multiple chapters within a single file`() = + assertFileResolution( + chapterDurations = listOf(5, 5, 10), + fileDurations = listOf(20), + expected = + listOf( + listOf(Clip("F0", 0, 5)), + listOf(Clip("F0", 5, 10)), + listOf(Clip("F0", 10, 20)), + ), + ) + + @Test + fun `chapters outlast available files`() = + assertFileResolution( + chapterDurations = listOf(10, 10), + fileDurations = listOf(15), + expected = + listOf( + listOf(Clip("F0", 0, 10)), + listOf(Clip("F0", 10, 15)), + ), + ) + + @Test + fun `files outlast available chapters`() = + assertFileResolution( + chapterDurations = listOf(10), + fileDurations = listOf(10, 10), + expected = + listOf( + listOf(Clip("F0", 0, 10)), + ), + ) + + @Test + fun `floating point inaccuracies`() = + assertFileResolution( + chapterDurations = listOf(10.000, 10.000), + fileDurations = listOf(10.0001, 9.9999), + expected = + listOf( + listOf(Clip("F0", 0, 10)), + listOf(Clip("F1", 0, 9.9999)), + ), + ) + + @Test + fun `complex overlapping mapping (more files)`() = + assertFileResolution( + chapterDurations = listOf(70, 70, 70, 70, 70), + fileDurations = listOf(50, 50, 50, 50, 50, 50, 50), + expected = + listOf( + listOf(Clip("F0", clipStart = 0, clipEnd = 50), Clip("F1", clipStart = 0, clipEnd = 20)), + listOf(Clip("F1", clipStart = 20, clipEnd = 50), Clip("F2", clipStart = 0, clipEnd = 40)), + listOf( + Clip("F2", clipStart = 40, clipEnd = 50), + Clip("F3", clipStart = 0, clipEnd = 50), + Clip("F4", clipStart = 0, clipEnd = 10), + ), + listOf(Clip("F4", clipStart = 10, clipEnd = 50), Clip("F5", clipStart = 0, clipEnd = 30)), + listOf(Clip("F5", clipStart = 30, clipEnd = 50), Clip("F6", clipStart = 0, clipEnd = 50)), + ), + ) + + @Test + fun `complex overlapping mapping (more chapters)`() = + assertFileResolution( + chapterDurations = listOf(50, 50, 50, 50, 50, 50, 50), + fileDurations = listOf(70, 70, 70, 70, 70), + expected = + listOf( + listOf(Clip("F0", clipStart = 0, clipEnd = 50)), + listOf(Clip("F0", clipStart = 50, clipEnd = 70), Clip("F1", clipStart = 0, clipEnd = 30)), + listOf( + Clip("F1", clipStart = 30, clipEnd = 70), + Clip("F2", clipStart = 0, clipEnd = 10), + ), + listOf(Clip("F2", clipStart = 10, clipEnd = 60)), + listOf(Clip("F2", clipStart = 60, clipEnd = 70), Clip("F3", clipStart = 0, clipEnd = 40)), + listOf(Clip("F3", clipStart = 40, clipEnd = 70), Clip("F4", clipStart = 0, clipEnd = 20)), + listOf(Clip("F4", clipStart = 20, clipEnd = 70)), + ), + ) + + private fun assertFileResolution( + chapterDurations: List, + fileDurations: List, + expected: List>, + ) { + val result = + PlaybackService.resolveChapterToFiles( + createChapters(chapterDurations), + createFiles(fileDurations), + ) + + assertEquals(expected.size, result.size) + expected.forEachIndexed { chapterIdx, expectedClips -> + assertEquals(expectedClips.size, result[chapterIdx].size) + expectedClips.forEachIndexed { clipIdx, expectedClip -> + val actual = result[chapterIdx][clipIdx] + assertEquals(expectedClip.fileId, actual.fileId) + assertEquals(expectedClip.clipStart.toDouble(), actual.clipStart, 0.00001) + assertEquals(expectedClip.clipEnd.toDouble(), actual.clipEnd, 0.00001) + } + } + } + + private fun createChapters(durations: List): List { + var previousChapterEnd = 0.0 + return durations.mapIndexed { index, duration -> + PlayingChapter( + id = "C$index", + title = "C$index", + start = previousChapterEnd, + end = previousChapterEnd + duration.toDouble(), + duration = duration.toDouble(), + available = true, + podcastEpisodeState = BookChapterState.FINISHED, + ).also { + previousChapterEnd += duration.toDouble() + } + } + } + + private fun createFiles(durations: List) = + durations.mapIndexed { index, duration -> + BookFile( + id = "F$index", + name = "F$index", + duration = duration.toDouble(), + mimeType = "audio/mpeg", + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f31813bd..cd8a6ade 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,8 +90,7 @@ moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", ver androidx-media3-ffmpeg-decoder = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version.ref = "media3Ffmpeg" } process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" } -junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "6.0.3" } - +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "junitJupiter" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }