Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sleep at end of chapter #2668

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,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 {
Expand All @@ -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()
}
}
}
}
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
Loading