From 83b14dabc75641208b2f3aa9ecf19539c3c7e5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=B6ller?= Date: Wed, 23 Aug 2023 17:38:58 +0200 Subject: [PATCH] Add sleep at end of chapter --- .../kotlin/voice/playback/PlayerController.kt | 12 +++++ .../voice/playback/di/PlaybackModule.kt | 13 +++-- .../playstate/PlayStateDelegatingListener.kt | 52 ++++++++++++++++++- .../playback/playstate/PlayStateManager.kt | 12 +++++ .../session/LibrarySessionCallback.kt | 4 +- .../voice/playback/session/SleepTimer.kt | 1 + .../playbackScreen/BookPlayController.kt | 1 + .../voice/playbackScreen/BookPlayViewModel.kt | 9 ++++ .../voice/playbackScreen/BookPlayViewState.kt | 1 + .../playbackScreen/view/BookPlayAppBar.kt | 2 +- .../playbackScreen/view/BookPlayContent.kt | 2 + .../voice/playbackScreen/view/BookPlayView.kt | 1 + .../voice/playbackScreen/view/CoverRow.kt | 16 ++++-- .../kotlin/voice/sleepTimer/SleepTimer.kt | 11 +++- .../voice/sleepTimer/SleepTimerDialog.kt | 10 ++++ strings/src/main/res/values-de/strings.xml | 1 + strings/src/main/res/values-es/strings.xml | 3 +- strings/src/main/res/values/strings.xml | 1 + 18 files changed, 138 insertions(+), 14 deletions(-) diff --git a/playback/src/main/kotlin/voice/playback/PlayerController.kt b/playback/src/main/kotlin/voice/playback/PlayerController.kt index cd142959a1..d1c1c085f3 100644 --- a/playback/src/main/kotlin/voice/playback/PlayerController.kt +++ b/playback/src/main/kotlin/voice/playback/PlayerController.kt @@ -144,6 +144,18 @@ class PlayerController controller.setPlaybackSpeed(speed) } + fun pauseAtStart() = executeAfterPrepare { + it.pause() + val bookId = currentBookId.data.first() ?: return@executeAfterPrepare + val book = bookRepository.get(bookId) ?: return@executeAfterPrepare + it.seekTo(book.currentMark.startMs) + } + + fun pauseAtTime(ms: Long) = executeAfterPrepare { + it.pause() + it.seekTo(ms) + } + fun setGain(gain: Decibel) = executeAfterPrepare { controller -> controller.sendCustomCommand(CustomCommand.SetGain(gain)) } diff --git a/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt b/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt index 85f974aa7b..529695c7df 100644 --- a/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt +++ b/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt @@ -16,6 +16,7 @@ import dagger.Provides import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -26,6 +27,7 @@ import voice.playback.player.OnlyAudioRenderersFactory import voice.playback.player.VoicePlayer import voice.playback.player.onAudioSessionIdChanged import voice.playback.playstate.PlayStateDelegatingListener +import voice.playback.playstate.PlayStateManager import voice.playback.playstate.PositionUpdater import voice.playback.session.LibrarySessionCallback import voice.playback.session.PlaybackService @@ -90,17 +92,20 @@ object PlaybackModule { scope: CoroutineScope, sleepTimer: SleepTimer, sleepTimerCommandUpdater: SleepTimerCommandUpdater, + playStateManager: PlayStateManager, ): MediaLibraryService.MediaLibrarySession { return MediaLibraryService.MediaLibrarySession.Builder(service, player, callback) .setSessionActivity(mainActivityIntentProvider.toCurrentBook()) .build() .also { session -> scope.launch { - sleepTimer.leftSleepTimeFlow - .map { it != Duration.ZERO } + sleepTimer.leftSleepTimeFlow.map { it != Duration.ZERO } + .combine(playStateManager.sleepAtEocFlow) { sleepTimerActive, sleepEocActive -> + sleepTimerActive || sleepEocActive + } .distinctUntilChanged() - .collect { sleepTimerActive -> - sleepTimerCommandUpdater.update(session, sleepTimerActive) + .collect { sleepActive -> + sleepTimerCommandUpdater.update(session, sleepActive) } } } diff --git a/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt b/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt index 8b670de9f0..6166207bf2 100644 --- a/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt +++ b/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt @@ -1,10 +1,25 @@ package voice.playback.playstate +import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.PlayerMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import voice.data.repo.ChapterRepo +import voice.playback.PlayerController +import voice.playback.session.MediaId +import voice.playback.session.toMediaIdOrNull import javax.inject.Inject class PlayStateDelegatingListener -@Inject constructor(private val playStateManager: PlayStateManager) : Player.Listener { +@Inject constructor( + private val playStateManager: PlayStateManager, + private val playerController: PlayerController, + private val chapterRepo: ChapterRepo, +) : Player.Listener { + private val scope = CoroutineScope(Dispatchers.Main.immediate) private lateinit var player: Player @@ -25,6 +40,19 @@ class PlayStateDelegatingListener updatePlayState() } + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + if (playStateManager.sleepAtEoc) { + playStateManager.sleepAtEoc = false + playerController.pauseAtStart() + } + if (player is ExoPlayer) { + registerChapterMarkCallbacks(player as ExoPlayer) + } + } + private fun updatePlayState() { val playbackState = player.playbackState playStateManager.playState = when { @@ -33,4 +61,26 @@ class PlayStateDelegatingListener else -> PlayStateManager.PlayState.Paused } } + + private fun registerChapterMarkCallbacks(player: ExoPlayer) { + scope.launch { + val currentMediaItem = player.currentMediaItem ?: return@launch + val mediaId = currentMediaItem.mediaId.toMediaIdOrNull() ?: return@launch + if (mediaId !is MediaId.Chapter) return@launch + val marks = chapterRepo.get(mediaId.chapterId)?.chapterMarks?.filter { mark -> mark.startMs != 0L } ?: return@launch + val boundaryHandler = PlayerMessage.Target { _, payload -> + if (playStateManager.sleepAtEoc) { + playerController.pauseAtTime(payload as Long) + playStateManager.sleepAtEoc = false + } + } + marks.forEach { mark -> + player.createMessage(boundaryHandler) + .setPosition(mark.startMs) + .setPayload(mark.startMs) + .setDeleteAfterDelivery(false) + .send() + } + } + } } diff --git a/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt b/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt index c84103c437..67d1c3566d 100644 --- a/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt +++ b/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt @@ -25,4 +25,16 @@ constructor() { Playing, Paused, } + + // Sleep at eoc state + private val _sleepAtEoc = MutableStateFlow(false) + + val sleepAtEocFlow: StateFlow + get() = _sleepAtEoc + + var sleepAtEoc: Boolean + set(value) { + _sleepAtEoc.value = value + } + get() = _sleepAtEoc.value } diff --git a/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt b/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt index 42967ef1fa..77aacb31ed 100644 --- a/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt +++ b/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt @@ -29,6 +29,7 @@ import voice.data.Book import voice.data.repo.BookRepository import voice.logging.core.Logger import voice.playback.player.VoicePlayer +import voice.playback.playstate.PlayStateManager import voice.playback.session.search.BookSearchHandler import voice.playback.session.search.BookSearchParser import javax.inject.Inject @@ -45,6 +46,7 @@ class LibrarySessionCallback private val sleepTimerCommandUpdater: SleepTimerCommandUpdater, private val sleepTimer: SleepTimer, private val bookRepository: BookRepository, + private val playStateManager: PlayStateManager, ) : MediaLibrarySession.Callback { override fun onAddMediaItems( @@ -205,7 +207,7 @@ class LibrarySessionCallback ): ListenableFuture { when (customCommand) { PublishedCustomCommand.Sleep.sessionCommand -> { - sleepTimer.setActive(!sleepTimer.sleepTimerActive()) + sleepTimer.setEocActive(!sleepTimer.sleepTimerActive()) } else -> { val command = CustomCommand.parse(customCommand, args) diff --git a/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt b/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt index 533929c0aa..287a3254b1 100644 --- a/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt +++ b/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt @@ -7,4 +7,5 @@ interface SleepTimer { val leftSleepTimeFlow: Flow fun sleepTimerActive(): Boolean fun setActive(enable: Boolean) + fun setEocActive(enable: Boolean) } diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayController.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayController.kt index 896e26062a..c23558e3b8 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayController.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayController.kt @@ -103,6 +103,7 @@ class BookPlayController(bundle: Bundle) : ComposeController(bundle) { onIncrementSleepTime = viewModel::incrementSleepTime, onDecrementSleepTime = viewModel::decrementSleepTime, onAcceptSleepTime = viewModel::onAcceptSleepTime, + onAcceptSleepAtEoc = viewModel::onAcceptSleepAtEoc, ) } } diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt index 460c6a72ad..5906c394e3 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt @@ -84,11 +84,13 @@ class BookPlayViewModel }.collectAsState() val sleepTime by remember { sleepTimer.leftSleepTimeFlow }.collectAsState() + val sleepAtEoc by remember { playStateManager.sleepAtEocFlow }.collectAsState() val currentMark = book.currentChapter.markForPosition(book.content.positionInChapter) val hasMoreThanOneChapter = book.chapters.sumOf { it.chapterMarks.count() } > 1 return BookPlayViewState( sleepTime = sleepTime, + sleepEoc = sleepAtEoc, playing = playState == PlayStateManager.PlayState.Playing, title = book.content.name, showPreviousNextButtons = hasMoreThanOneChapter, @@ -145,6 +147,13 @@ class BookPlayViewModel } } + fun onAcceptSleepAtEoc() { + updateSleepTimeViewState { + sleepTimer.setEocActive(true) + null + } + } + private fun updateSleepTimeViewState(update: (SleepTimerViewState) -> SleepTimerViewState?) { val current = dialogState.value val updated: SleepTimerViewState? = if (current is BookPlayDialogViewState.SleepTimer) { diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewState.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewState.kt index cf44031126..d2093e2559 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewState.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewState.kt @@ -13,6 +13,7 @@ data class BookPlayViewState( val showPreviousNextButtons: Boolean, val title: String, val sleepTime: Duration, + val sleepEoc: Boolean, val playedTime: Duration, val duration: Duration, val playing: Boolean, diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayAppBar.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayAppBar.kt index 6954d055eb..bbfe966c09 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayAppBar.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayAppBar.kt @@ -40,7 +40,7 @@ internal fun BookPlayAppBar( val appBarActions: @Composable RowScope.() -> Unit = { IconButton(onClick = onSleepTimerClick) { Icon( - imageVector = if (viewState.sleepTime == Duration.ZERO) { + imageVector = if (!viewState.sleepEoc && viewState.sleepTime == Duration.ZERO) { Icons.Outlined.Bedtime } else { Icons.Outlined.BedtimeOff diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayContent.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayContent.kt index f71d4920c1..563e86d518 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayContent.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayContent.kt @@ -34,6 +34,7 @@ internal fun BookPlayContent( cover = viewState.cover, onPlayClick = onPlayClick, sleepTime = viewState.sleepTime, + sleepEoc = viewState.sleepEoc, modifier = Modifier .fillMaxHeight() .weight(1F) @@ -75,6 +76,7 @@ internal fun BookPlayContent( onPlayClick = onPlayClick, cover = viewState.cover, sleepTime = viewState.sleepTime, + sleepEoc = viewState.sleepEoc, modifier = Modifier .fillMaxWidth() .weight(1F) diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayView.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayView.kt index 858b6cbd2d..3f57a15924 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayView.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/view/BookPlayView.kt @@ -106,6 +106,7 @@ private class BookPlayViewStatePreviewProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { Box(modifier) { Cover(onDoubleClick = onPlayClick, cover = cover) - if (sleepTime != Duration.ZERO) { + if (sleepTime != Duration.ZERO || sleepEoc) { Text( modifier = Modifier .align(Alignment.TopEnd) @@ -33,10 +36,13 @@ internal fun CoverRow( shape = RoundedCornerShape(20.dp), ) .padding(horizontal = 20.dp, vertical = 16.dp), - text = formatTime( - timeMs = sleepTime.inWholeMilliseconds, - durationMs = sleepTime.inWholeMilliseconds, - ), + text = when (sleepEoc) { + true -> stringResource(R.string.end_of_chapter) + false -> formatTime( + timeMs = sleepTime.inWholeMilliseconds, + durationMs = sleepTime.inWholeMilliseconds, + ) + }, color = Color.White, ) } diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt index 5cbe945b45..6613a43d7d 100644 --- a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt +++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt @@ -49,7 +49,7 @@ class SleepTimer } override val leftSleepTimeFlow: StateFlow get() = _leftSleepTime - override fun sleepTimerActive(): Boolean = sleepJob?.isActive == true && leftSleepTime > Duration.ZERO + override fun sleepTimerActive(): Boolean = (sleepJob?.isActive == true && leftSleepTime > Duration.ZERO) || playStateManager.sleepAtEoc private var sleepJob: Job? = null @@ -62,6 +62,14 @@ class SleepTimer } } + override fun setEocActive(enable: Boolean) { + if (enable) { + playStateManager.sleepAtEoc = true + } else { + cancel() + } + } + fun setActive(sleepTime: Duration = sleepTimePref.value.minutes) { Logger.i("Starting sleepTimer. Pause in $sleepTime.") leftSleepTime = sleepTime @@ -121,5 +129,6 @@ class SleepTimer sleepJob?.cancel() leftSleepTime = Duration.ZERO playerController.setVolume(1F) + playStateManager.sleepAtEoc = false } } diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialog.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialog.kt index 2a7f04d63e..8aa40da906 100644 --- a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialog.kt +++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialog.kt @@ -35,6 +35,7 @@ fun SleepTimerDialog( onIncrementSleepTime: () -> Unit, onDecrementSleepTime: () -> Unit, onAcceptSleepTime: (Int) -> Unit, + onAcceptSleepAtEoc: () -> Unit, modifier: Modifier = Modifier, ) { ModalBottomSheet( @@ -63,6 +64,15 @@ fun SleepTimerDialog( }, ) } + ListItem( + modifier = Modifier.clickable { + onAcceptSleepAtEoc() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { + Text(text = stringResource(id = StringsR.string.end_of_chapter)) + }, + ) ListItem( modifier = Modifier.clickable { onAcceptSleepTime(viewState.customSleepTime) diff --git a/strings/src/main/res/values-de/strings.xml b/strings/src/main/res/values-de/strings.xml index 6832fcb964..f09feb7c6f 100644 --- a/strings/src/main/res/values-de/strings.xml +++ b/strings/src/main/res/values-de/strings.xml @@ -92,6 +92,7 @@ Einschlaftimer ausschalten Abspielen Pause + Kapitelende Etwas ist schief gelaufen 😢 Erneut versuchen %1$s von %2$s Hörbuch Titelbild diff --git a/strings/src/main/res/values-es/strings.xml b/strings/src/main/res/values-es/strings.xml index 4a9f4b65fb..7da609b3e7 100644 --- a/strings/src/main/res/values-es/strings.xml +++ b/strings/src/main/res/values-es/strings.xml @@ -94,6 +94,7 @@ Reciente Pausar Reproducir + Fin del capítulo Reintentar Algo salió mal 😢 %s portada del audiolibro @@ -133,4 +134,4 @@ %d minutos %d minutos - \ No newline at end of file + diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index 1bb9240c77..0c42769ff9 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -95,6 +95,7 @@ Recent Enable Sleep Timer Disable Sleep Timer + End of Chapter Something went wrong 😢 Retry Permission required