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" }