mirror of
https://github.com/GrakovNe/lissen-android.git
synced 2026-07-31 10:28:12 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user