Merge pull request #1 from GrakovNe/feature/chapters-instead-files

Feature/chapters instead files
This commit is contained in:
Max Grakov
2024-10-15 18:58:36 +02:00
committed by GitHub
10 changed files with 151 additions and 89 deletions

View File

@@ -34,7 +34,7 @@ android {
debug {
matchingFallbacks.add("release")
isMinifyEnabled = false
isDebuggable = false
isDebuggable = true
}
}

View File

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

View File

@@ -25,6 +25,7 @@ data class MediaProgress(
) : Serializable
data class BookChapter(
val duration: Double,
val start: Double,
val end: Double,
val title: String,

View File

@@ -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<Boolean> = _isPlaybackReady
private val _currentPosition = MutableLiveData<Long>()
val currentPosition: LiveData<Long> = _currentPosition
private val _currentMediaItemIndex = MutableLiveData<Int>()
val currentMediaItemIndex: LiveData<Int> = _currentMediaItemIndex
private val _mediaItemPosition = MutableLiveData<Double>()
val mediaItemPosition: LiveData<Double> = _mediaItemPosition
private val _playingBook = MutableLiveData<DetailedBook>()
val playingBook: LiveData<DetailedBook> = _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)
}

View File

@@ -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<BookFile>,
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<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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DetailedBook> = mediaRepository.playingBook
private val mediaItemPosition: LiveData<Double> = mediaRepository.mediaItemPosition
private val _playingQueueExpanded = MutableLiveData(false)
val playingQueueExpanded: LiveData<Boolean> = _playingQueueExpanded
val isPlaybackReady: LiveData<Boolean> = mediaRepository.isPlaybackReady
val isPlaying: LiveData<Boolean> = mediaRepository.isPlaying
val currentPosition: LiveData<Long> = mediaRepository.currentPosition
val currentTrackIndex: LiveData<Int> = mediaRepository.currentMediaItemIndex
private val _currentChapterIndex = MediatorLiveData<Int>().apply {
addSource(mediaItemPosition) { updateCurrentTrackData() }
addSource(book) { updateCurrentTrackData() }
}
val currentChapterIndex: LiveData<Int> = _currentChapterIndex
private val _currentChapterPosition = MediatorLiveData<Double>().apply {
addSource(mediaItemPosition) { updateCurrentTrackData() }
addSource(book) { updateCurrentTrackData() }
}
val currentChapterPosition: LiveData<Double> = _currentChapterPosition
private val _currentChapterDuration = MediatorLiveData<Double>().apply {
addSource(mediaItemPosition) { updateCurrentTrackData() }
addSource(book) { updateCurrentTrackData() }
}
val currentChapterDuration: LiveData<Double> = _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
}
}