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
This commit is contained in:
Michał Goliński
2026-02-26 14:02:40 +01:00
committed by GitHub
parent d5325fa79d
commit 013e5fa15d
12 changed files with 473 additions and 168 deletions

View File

@@ -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 {

View File

@@ -97,7 +97,7 @@ class LissenMediaProvider
detailedItem: DetailedItem,
progress: PlaybackProgress,
): OperationResult<Unit> {
Timber.d("Syncing Progress for $detailedItem. $progress")
Timber.d("Syncing Progress for ${detailedItem.id}. $progress")
localCacheRepository.syncProgress(detailedItem, progress)

View File

@@ -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<SubtitleConfiguration> = 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<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<MediaItemsWithStartPosition> {
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<MediaItemsWithStartPosition> =
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<String, ListenableFuture<List<MediaItem>>>(3)

View File

@@ -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

View File

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

View File

@@ -464,7 +464,7 @@ class MediaRepository
private fun updateProgress(detailedItem: DetailedItem): Deferred<Unit> =
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)

View File

@@ -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)
}
}

View File

@@ -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<BookFile>,
items: List<PlayingChapter>,
position: Double?,
) {
if (items.isEmpty()) {
@@ -293,11 +259,6 @@ class PlaybackService : MediaLibraryService() {
}
}
private fun setPlaybackProgress(
chapters: List<BookFile>,
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<PlayingChapter>,
files: List<BookFile>,
): List<ArrayList<FileClip>> = resolveChapterToFiles(chapters, files) { index, chapter, resolvedFiles -> resolvedFiles }
internal fun <T> resolveChapterToFiles(
chapters: List<PlayingChapter>,
files: List<BookFile>,
resolvedFilesConsumer: (Int, PlayingChapter, ArrayList<FileClip>) -> T,
): List<T> {
if (files.isEmpty() || chapters.isEmpty()) return emptyList()
val result = ArrayList<T>(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<FileClip>(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())
}
}
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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<Number>,
fileDurations: List<Number>,
expected: List<List<Clip>>,
) {
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<Number>): List<PlayingChapter> {
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<Number>) =
durations.mapIndexed { index, duration ->
BookFile(
id = "F$index",
name = "F$index",
duration = duration.toDouble(),
mimeType = "audio/mpeg",
)
}
}

View File

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