mirror of
https://github.com/GrakovNe/lissen-android.git
synced 2026-07-31 18:37:33 -04:00
Merge pull request #1 from GrakovNe/feature/chapters-instead-files
Feature/chapters instead files
This commit is contained in:
@@ -34,7 +34,7 @@ android {
|
||||
debug {
|
||||
matchingFallbacks.add("release")
|
||||
isMinifyEnabled = false
|
||||
isDebuggable = false
|
||||
isDebuggable = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ data class MediaProgress(
|
||||
) : Serializable
|
||||
|
||||
data class BookChapter(
|
||||
val duration: Double,
|
||||
val start: Double,
|
||||
val end: Double,
|
||||
val title: String,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user