diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acc35578..1563ab65 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,7 +34,7 @@ android { debug { matchingFallbacks.add("release") isMinifyEnabled = false - isDebuggable = false + isDebuggable = true } } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/converter/LibraryItemIdResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/converter/LibraryItemIdResponseConverter.kt index 0d00278c..dce708fa 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/converter/LibraryItemIdResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/converter/LibraryItemIdResponseConverter.kt @@ -42,7 +42,8 @@ class LibraryItemIdResponseConverter @Inject constructor() { start = it.start, end = it.end, title = it.title, - id = it.id + id = it.id, + duration = it.end - it.start ) }, progress = progressResponse diff --git a/app/src/main/java/org/grakovne/lissen/domain/DetailedBook.kt b/app/src/main/java/org/grakovne/lissen/domain/DetailedBook.kt index 23270e31..09a7a81f 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/DetailedBook.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/DetailedBook.kt @@ -25,6 +25,7 @@ data class MediaProgress( ) : Serializable data class BookChapter( + val duration: Double, val start: Double, val end: Double, val title: String, diff --git a/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt index f0b225de..c7dc03b0 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt @@ -11,7 +11,6 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken @@ -42,11 +41,8 @@ class MediaRepository @Inject constructor(@ApplicationContext private val contex private val _isPlaybackReady = MutableLiveData(false) val isPlaybackReady: LiveData = _isPlaybackReady - private val _currentPosition = MutableLiveData() - val currentPosition: LiveData = _currentPosition - - private val _currentMediaItemIndex = MutableLiveData() - val currentMediaItemIndex: LiveData = _currentMediaItemIndex + private val _mediaItemPosition = MutableLiveData() + val mediaItemPosition: LiveData = _mediaItemPosition private val _playingBook = MutableLiveData() val playingBook: LiveData = _playingBook @@ -76,17 +72,10 @@ class MediaRepository @Inject constructor(@ApplicationContext private val contex .registerReceiver(bookDetailsReadyReceiver, IntentFilter(PLAYBACK_READY)) mediaController.addListener(object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - _currentMediaItemIndex.value = mediaController.currentMediaItemIndex - } - override fun onIsPlayingChanged(isPlaying: Boolean) { _isPlaying.value = isPlaying - _currentMediaItemIndex.value = mediaController.currentMediaItemIndex } }) - - startUpdatingProgress() } override fun onFailure(t: Throwable) { @@ -106,11 +95,11 @@ class MediaRepository @Inject constructor(@ApplicationContext private val contex preparePlay(book) } _playingBook.postValue(book) + startUpdatingProgress(book) } private fun preparePlay(book: DetailedBook) { - _currentPosition.postValue(0) - _currentMediaItemIndex.postValue(0) + _mediaItemPosition.postValue(0.0) _isPlaying.postValue(false) val intent = Intent(context, AudioPlayerService::class.java).apply { @@ -135,44 +124,28 @@ class MediaRepository @Inject constructor(@ApplicationContext private val contex context.startService(intent) } - fun nextTrack() { - mediaController.run { - if (currentMediaItemIndex + 1 < currentTimeline.windowCount) { - seekTo(currentMediaItemIndex + 1, 0) - _currentPosition.value = 0 - } - } - } - - fun previousTrack() { - mediaController.run { - if (currentMediaItemIndex > 0) { - seekTo(currentMediaItemIndex - 1, 0) - _currentPosition.value = 0 - } - } - } - - fun setTrack(index: Int) { - if (index in 0 until mediaController.currentTimeline.windowCount) { - mediaController.seekTo(index, 0) - } - } - - fun seekTo(position: Float) { + fun seekTo(position: Double) { val intent = Intent(context, AudioPlayerService::class.java).apply { action = ACTION_SEEK_TO + + putExtra(BOOK_EXTRA, playingBook.value) putExtra(POSITION, position) } context.startService(intent) } - private fun startUpdatingProgress() { + private fun startUpdatingProgress(detailedBook: DetailedBook) { + handler.removeCallbacksAndMessages(null) + handler.postDelayed(object : Runnable { override fun run() { - _currentPosition.value = mediaController.currentPosition / 1000 - handler.postDelayed(this, 500) + val currentIndex = mediaController.currentMediaItemIndex + val accumulated = detailedBook.files.take(currentIndex).sumOf { it.duration } + val currentFilePosition = mediaController.currentPosition / 1000.0 + + _mediaItemPosition.value = (accumulated + currentFilePosition) + handler.postDelayed(this, 1000) } }, 500) } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt index 0db4a438..6975e05e 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -82,8 +82,9 @@ class AudioPlayerService : MediaSessionService() { } ACTION_SEEK_TO -> { - val position = intent.getFloatExtra(POSITION, 0f) - exoPlayer.seekTo((position * 1000).toLong()) + val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedBook + val position = intent.getDoubleExtra(POSITION, 0.0) + book?.let { seek(it.files, position) } return START_NOT_STICKY } @@ -110,8 +111,6 @@ class AudioPlayerService : MediaSessionService() { exoPlayer.playWhenReady = false withContext(Dispatchers.IO) { - //playbackSynchronizationService.stopPlaybackSynchronization() - val prepareQueue = async { val playingQueue = book .files @@ -151,22 +150,27 @@ class AudioPlayerService : MediaSessionService() { } - private fun setPlaybackProgress( + private fun seek( chapters: List, - progress: MediaProgress? + position: Double? ) { - when (progress) { + when (position) { null -> exoPlayer.seekTo(0, 0) else -> { val duration = chapters.runningFold(0.0) { acc, chapter -> acc + chapter.duration } - val targetChapter = duration.indexOfFirst { it > progress.currentTime } - val chapterProgress = progress.currentTime - duration[targetChapter - 1] + val targetChapter = duration.indexOfFirst { it > position } + val chapterProgress = position - duration[targetChapter - 1] exoPlayer.seekTo(targetChapter - 1, (chapterProgress * 1000).toLong()) } } } + 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" diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt index a38604c2..8a9d03cb 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt @@ -34,10 +34,10 @@ fun PlayingQueueComposable( modifier: Modifier = Modifier, ) { val isPlaybackReady by viewModel.isPlaybackReady.observeAsState(false) - val currentTrackIndex by viewModel.currentTrackIndex.observeAsState(0) - val book by viewModel.book.observeAsState() - val chapters = book?.files ?: emptyList() + val book by viewModel.book.observeAsState() + val chapters = book?.chapters ?: emptyList() + val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) val playingQueueExpanded by viewModel.playingQueueExpanded.observeAsState(false) val listState = rememberLazyListState() @@ -61,7 +61,6 @@ fun PlayingQueueComposable( when { currentTrackIndex > 0 -> listState.animateScrollToItem(currentTrackIndex - 1) else -> listState.animateScrollToItem(currentTrackIndex) - } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt index 2dadf8e5..b976bf87 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt @@ -23,12 +23,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.grakovne.lissen.R +import org.grakovne.lissen.domain.BookChapter import org.grakovne.lissen.domain.BookFile import org.grakovne.lissen.ui.extensions.formatLeadingMinutes @Composable fun PlaylistItemComposable( - track: BookFile, + track: BookChapter, isSelected: Boolean, onClick: () -> Unit ) { @@ -56,7 +57,7 @@ fun PlaylistItemComposable( Spacer(modifier = Modifier.width(8.dp)) Text( - text = track.name, + text = track.title, style = MaterialTheme.typography.titleMedium, color = colorScheme.onBackground, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt index 2ff7d1e5..97a5ba4b 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt @@ -23,9 +23,9 @@ import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,26 +42,22 @@ fun TrackControlComposable( modifier: Modifier = Modifier ) { val isPlaying by viewModel.isPlaying.observeAsState(false) - val currentPosition by viewModel.currentPosition.observeAsState(0L) - val currentTrackIndex by viewModel.currentTrackIndex.observeAsState(0) + + val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) + val currentTrackPosition by viewModel.currentChapterPosition.observeAsState(0.0) + val currentTrackDuration by viewModel.currentChapterDuration.observeAsState(0.0) val book by viewModel.book.observeAsState() - val chapters = book?.files ?: emptyList() + val chapters = book?.chapters ?: emptyList() - var sliderPosition by remember { mutableFloatStateOf(0f) } + var sliderPosition by remember { mutableDoubleStateOf(0.0) } var isDragging by remember { mutableStateOf(false) } - val duration by remember { - derivedStateOf { - book?.files?.getOrNull(currentTrackIndex)?.duration?.toFloat() ?: 0f - } - } - - LaunchedEffect(currentPosition) { + LaunchedEffect(currentTrackPosition, currentTrackIndex, currentTrackDuration) { when (isDragging) { true -> {} false -> { - sliderPosition = currentPosition.toFloat() + sliderPosition = currentTrackPosition } } } @@ -71,16 +67,16 @@ fun TrackControlComposable( ) { Slider( - value = sliderPosition, + value = sliderPosition.toFloat(), onValueChange = { newPosition -> isDragging = true - sliderPosition = newPosition + sliderPosition = newPosition.toDouble() }, onValueChangeFinished = { isDragging = false viewModel.seekTo(sliderPosition) }, - valueRange = 0f..(duration), + valueRange = 0f..currentTrackDuration.toFloat(), colors = SliderDefaults .colors( thumbColor = colorScheme.primary, @@ -95,12 +91,14 @@ fun TrackControlComposable( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = currentPosition.toInt().formatFully(), + text = currentTrackPosition.toInt().formatFully(), style = MaterialTheme.typography.bodySmall, color = colorScheme.onBackground.copy(alpha = 0.6f) ) Text( - text = "-${maxOf(0f, duration - currentPosition).toInt().formatFully()}", + text = "-${ + maxOf(0.0, currentTrackDuration - currentTrackPosition).toInt().formatFully() + }", style = MaterialTheme.typography.bodySmall, color = colorScheme.onBackground.copy(alpha = 0.6f) ) @@ -131,7 +129,7 @@ fun TrackControlComposable( IconButton( onClick = { - viewModel.seekTo(maxOf(0f, currentPosition - 10f)) + viewModel.seekTo(maxOf(0.0, currentTrackPosition - 10L)) }, modifier = Modifier.weight(1f) ) { @@ -159,7 +157,7 @@ fun TrackControlComposable( IconButton( onClick = { - viewModel.seekTo(minOf(duration, currentPosition + 30f)) + viewModel.seekTo(minOf(currentTrackDuration, currentTrackPosition + 30.0)) }, modifier = Modifier.weight(1f) ) { diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt index 1e51e8b3..f88a6c63 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt @@ -36,7 +36,8 @@ fun TrackDetailsComposable( viewModel: PlayerViewModel, modifier: Modifier = Modifier ) { - val currentTrackIndex by viewModel.currentTrackIndex.observeAsState(0) + + val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) val book by viewModel.book.observeAsState() val context = LocalContext.current @@ -89,7 +90,7 @@ fun TrackDetailsComposable( text = stringResource( R.string.player_screen_now_playing_title_chapter_of, currentTrackIndex + 1, - book?.files?.size ?: "?" + book?.chapters?.size ?: "?" ), style = typography.bodyMedium, color = colorScheme.onBackground.copy(alpha = 0.6f) diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt index eabb481f..42e3cd59 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt @@ -1,6 +1,7 @@ package org.grakovne.lissen.viewmodel import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -21,18 +22,52 @@ class PlayerViewModel @Inject constructor( val book: LiveData = mediaRepository.playingBook + private val mediaItemPosition: LiveData = mediaRepository.mediaItemPosition private val _playingQueueExpanded = MutableLiveData(false) + val playingQueueExpanded: LiveData = _playingQueueExpanded val isPlaybackReady: LiveData = mediaRepository.isPlaybackReady val isPlaying: LiveData = mediaRepository.isPlaying - val currentPosition: LiveData = mediaRepository.currentPosition - val currentTrackIndex: LiveData = mediaRepository.currentMediaItemIndex + + private val _currentChapterIndex = MediatorLiveData().apply { + addSource(mediaItemPosition) { updateCurrentTrackData() } + addSource(book) { updateCurrentTrackData() } + } + val currentChapterIndex: LiveData = _currentChapterIndex + + private val _currentChapterPosition = MediatorLiveData().apply { + addSource(mediaItemPosition) { updateCurrentTrackData() } + addSource(book) { updateCurrentTrackData() } + } + val currentChapterPosition: LiveData = _currentChapterPosition + + private val _currentChapterDuration = MediatorLiveData().apply { + addSource(mediaItemPosition) { updateCurrentTrackData() } + addSource(book) { updateCurrentTrackData() } + } + val currentChapterDuration: LiveData = _currentChapterDuration fun togglePlayingQueue() { _playingQueueExpanded.value = !(_playingQueueExpanded.value ?: false) } + private fun updateCurrentTrackData() { + val book = book.value ?: return + val position = mediaRepository.mediaItemPosition.value ?: return + + val trackIndex = calculateChapterIndex(position) + val trackPosition = calculateChapterPosition(position) + + _currentChapterIndex.value = trackIndex + _currentChapterPosition.value = trackPosition + _currentChapterDuration.value = book + .chapters + .getOrNull(trackIndex) + ?.duration + ?: 0.0 + } + fun preparePlayback(bookId: String) { viewModelScope.launch { val result = withContext(Dispatchers.IO) { @@ -52,20 +87,39 @@ class PlayerViewModel @Inject constructor( } } - fun seekTo(position: Float) { - mediaRepository.seekTo(position) + fun seekTo(chapterPosition: Double) { + val absolutePosition = currentChapterIndex.value + ?.let { chapterIndex -> + book + .value + ?.chapters + ?.get(chapterIndex) + ?.start + } + ?.let { it + chapterPosition } ?: return + + mediaRepository.seekTo(absolutePosition) } fun setChapter(index: Int) { - mediaRepository.setTrack(index) + val chapterStartsAt = book + .value + ?.chapters + ?.get(index) + ?.start + ?: 0.0 + + mediaRepository.seekTo(chapterStartsAt) } fun nextTrack() { - mediaRepository.nextTrack() + val nextChapterIndex = currentChapterIndex.value?.let { it + 1 } ?: return + setChapter(nextChapterIndex) } fun previousTrack() { - mediaRepository.previousTrack() + val previousChapterIndex = currentChapterIndex.value?.let { it - 1 } ?: return + setChapter(previousChapterIndex) } fun togglePlayPause() { @@ -83,4 +137,34 @@ class PlayerViewModel @Inject constructor( private fun pause() { mediaRepository.pauseAudio() } + + + private fun calculateChapterIndex(position: Double): Int { + val currentBook = book.value ?: return 0 + var accumulatedDuration = 0.0 + + for ((index, chapter) in currentBook.chapters.withIndex()) { + accumulatedDuration += chapter.duration + if (position < accumulatedDuration) { + return index + } + } + + return currentBook.chapters.size - 1 + } + + private fun calculateChapterPosition(overallPosition: Double): Double { + val currentBook = book.value ?: return 0.0 + var accumulatedDuration = 0.0 + + for (chapter in currentBook.chapters) { + val chapterEnd = accumulatedDuration + chapter.duration + if (overallPosition < chapterEnd) { + return (overallPosition - accumulatedDuration) + } + accumulatedDuration = chapterEnd + } + + return 0.0 + } } \ No newline at end of file