Skip to content

Commit

Permalink
Add sleep at end of chapter
Browse files Browse the repository at this point in the history
  • Loading branch information
tomaThomas committed Jan 5, 2025
1 parent 0be5cba commit 95d93ab
Show file tree
Hide file tree
Showing 18 changed files with 137 additions and 14 deletions.
12 changes: 12 additions & 0 deletions playback/src/main/kotlin/voice/playback/PlayerController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
13 changes: 9 additions & 4 deletions playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -25,6 +40,18 @@ 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 {
Expand All @@ -33,4 +60,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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,16 @@ constructor() {
Playing,
Paused,
}

// Sleep at eoc state
private val _sleepAtEoc = MutableStateFlow(false)

val sleepAtEocFlow: StateFlow<Boolean>
get() = _sleepAtEoc

var sleepAtEoc: Boolean
set(value) {
_sleepAtEoc.value = value
}
get() = _sleepAtEoc.value
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -205,7 +207,7 @@ class LibrarySessionCallback
): ListenableFuture<SessionResult> {
when (customCommand) {
PublishedCustomCommand.Sleep.sessionCommand -> {
sleepTimer.setActive(!sleepTimer.sleepTimerActive())
sleepTimer.setEocActive(!sleepTimer.sleepTimerActive())
}
else -> {
val command = CustomCommand.parse(customCommand, args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ interface SleepTimer {
val leftSleepTimeFlow: Flow<Duration>
fun sleepTimerActive(): Boolean
fun setActive(enable: Boolean)
fun setEocActive(enable: Boolean)
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class BookPlayController(bundle: Bundle) : ComposeController(bundle) {
onIncrementSleepTime = viewModel::incrementSleepTime,
onDecrementSleepTime = viewModel::decrementSleepTime,
onAcceptSleepTime = viewModel::onAcceptSleepTime,
onAcceptSleepAtEoc = viewModel::onAcceptSleepAtEoc,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ internal fun BookPlayContent(
cover = viewState.cover,
onPlayClick = onPlayClick,
sleepTime = viewState.sleepTime,
sleepEoc = viewState.sleepEoc,
modifier = Modifier
.fillMaxHeight()
.weight(1F)
Expand Down Expand Up @@ -75,6 +76,7 @@ internal fun BookPlayContent(
onPlayClick = onPlayClick,
cover = viewState.cover,
sleepTime = viewState.sleepTime,
sleepEoc = viewState.sleepEoc,
modifier = Modifier
.fillMaxWidth()
.weight(1F)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ private class BookPlayViewStatePreviewProvider : PreviewParameterProvider<BookPl
playing = true,
skipSilence = true,
sleepTime = 4.minutes,
sleepEoc = false,
title = "Das Ende der Welt",
)
yield(initial)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import voice.common.compose.ImmutableFile
import voice.common.formatTime
import voice.strings.R
import kotlin.time.Duration

@Composable
internal fun CoverRow(
cover: ImmutableFile?,
sleepTime: Duration,
sleepEoc: Boolean,
onPlayClick: () -> 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)
Expand All @@ -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,
)
}
Expand Down
11 changes: 10 additions & 1 deletion sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class SleepTimer
}
override val leftSleepTimeFlow: StateFlow<Duration> 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

Expand All @@ -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
Expand Down Expand Up @@ -121,5 +129,6 @@ class SleepTimer
sleepJob?.cancel()
leftSleepTime = Duration.ZERO
playerController.setVolume(1F)
playStateManager.sleepAtEoc = false
}
}
10 changes: 10 additions & 0 deletions sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ fun SleepTimerDialog(
onIncrementSleepTime: () -> Unit,
onDecrementSleepTime: () -> Unit,
onAcceptSleepTime: (Int) -> Unit,
onAcceptSleepAtEoc: () -> Unit,
modifier: Modifier = Modifier,
) {
ModalBottomSheet(
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions strings/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
<string name="notification_sleep_timer_disable">Einschlaftimer ausschalten</string>
<string name="play">Abspielen</string>
<string name="pause">Pause</string>
<string name="end_of_chapter">Kapitelende</string>
<string name="generic_error_message">Etwas ist schief gelaufen 😢</string>
<string name="generic_error_retry">Erneut versuchen</string>
<string name="cover_search_template_with_author">%1$s von %2$s Hörbuch Titelbild</string>
Expand Down
3 changes: 2 additions & 1 deletion strings/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
<string name="media_session_recent">Reciente</string>
<string name="pause">Pausar</string>
<string name="play">Reproducir</string>
<string name="end_of_chapter">Fin del capítulo</string>
<string name="generic_error_retry">Reintentar</string>
<string name="generic_error_message">Algo salió mal 😢</string>
<string name="cover_search_template_no_author">%s portada del audiolibro</string>
Expand Down Expand Up @@ -133,4 +134,4 @@
<item quantity="many">%d minutos</item>
<item quantity="other">%d minutos</item>
</plurals>
</resources>
</resources>
1 change: 1 addition & 0 deletions strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
<string name="media_session_recent">Recent</string>
<string name="notification_sleep_timer_enable">Enable Sleep Timer</string>
<string name="notification_sleep_timer_disable">Disable Sleep Timer</string>
<string name="end_of_chapter">End of Chapter</string>
<string name="generic_error_message">Something went wrong 😢</string>
<string name="generic_error_retry">Retry</string>
<string name="storage_bug_title">Permission required</string>
Expand Down

0 comments on commit 95d93ab

Please sign in to comment.