diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c86a4b705..5e01c855197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Bug Fixes * Fix global auto download setting was incorrectly overriding the podcast auto download setting ([#3342](https://github.com/Automattic/pocket-casts-android/pull/3342)) + * Fix sleep timer was not stopping as expected + ([#3377](https://github.com/Automattic/pocket-casts-android/pull/3377)) 7.79 ----- diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eeda726aea1..2708dd934cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -533,8 +533,6 @@ android:permission="android.permission.BIND_JOB_SERVICE" android:exported="true"/> - - diff --git a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt index 737cf122ae8..91cc08ba99b 100644 --- a/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt +++ b/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModel.kt @@ -1,6 +1,7 @@ package au.com.shiftyjelly.pocketcasts.player.viewmodel import android.content.Context +import android.text.format.DateUtils import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -34,6 +35,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.download.DownloadManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackState import au.com.shiftyjelly.pocketcasts.repositories.playback.SleepTimer +import au.com.shiftyjelly.pocketcasts.repositories.playback.SleepTimerState import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextSource import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager @@ -185,6 +187,7 @@ class PlayerViewModel @Inject constructor( chaptersExpandedObservable, settings.useRealTimeForPlaybackRemaingTime.flow.asObservable(coroutineContext), settings.artworkConfiguration.flow.asObservable(coroutineContext), + sleepTimer.stateFlow.asObservable(coroutineContext), this::mergeListData, ) .distinctUntilChanged() @@ -340,6 +343,7 @@ class PlayerViewModel @Inject constructor( chaptersExpanded: Boolean, adjustRemainingTimeDuration: Boolean, artworkConfiguration: ArtworkConfiguration, + sleepTimerState: SleepTimerState, ): ListData { val podcast: Podcast? = (upNextState as? UpNextQueue.State.Loaded)?.podcast val episode = (upNextState as? UpNextQueue.State.Loaded)?.episode @@ -357,7 +361,7 @@ class PlayerViewModel @Inject constructor( if (episode == null) { podcastHeader = PlayerHeader() } else { - isSleepRunning.postValue(playbackState.isSleepTimerRunning) + isSleepRunning.postValue(sleepTimerState.isSleepTimerRunning) val playerBackground = theme.playerBackgroundColor(podcast) val iconTintColor = theme.playerHighlightColor(podcast) @@ -374,7 +378,7 @@ class PlayerViewModel @Inject constructor( podcastTitle = if (playbackState.chapters.isEmpty) podcast?.title else null, skipBackwardInSecs = skipBackwardInSecs, skipForwardInSecs = skipForwardInSecs, - isSleepRunning = playbackState.isSleepTimerRunning, + isSleepRunning = sleepTimerState.isSleepTimerRunning, isEffectsOn = !effects.usingDefaultValues, playbackEffects = effects, adjustRemainingTimeDuration = adjustRemainingTimeDuration, @@ -591,55 +595,53 @@ class PlayerViewModel @Inject constructor( } fun updateSleepTimer() { - val timeLeft = sleepTimer.timeLeftInSecs() - if ((sleepTimer.isSleepAfterTimerRunning && timeLeft != null && timeLeft.toInt() > 0) || playbackManager.isSleepAfterEpisodeEnabled()) { + val timeLeft = timeLeftInSeconds() + if ((sleepTimer.state.isSleepTimerRunning && timeLeft > 0) || playbackManager.isSleepAfterEpisodeEnabled()) { isSleepAtEndOfEpisodeOrChapter.postValue(playbackManager.isSleepAfterEpisodeEnabled()) - sleepTimeLeftText.postValue(if (timeLeft != null && timeLeft > 0) Util.formattedSeconds(timeLeft.toDouble()) else "") - setSleepEndOfEpisodes(playbackManager.episodesUntilSleep, shouldCallUpdateTimer = false) + sleepTimeLeftText.postValue(if (timeLeft > 0) Util.formattedSeconds(timeLeft.toDouble()) else "") + setSleepEndOfEpisodes(sleepTimer.state.numberOfEpisodesLeft, shouldCallUpdateTimer = false) sleepingInText.postValue(calcSleepingInEpisodesText()) } else if (playbackManager.isSleepAfterChapterEnabled()) { isSleepAtEndOfEpisodeOrChapter.postValue(playbackManager.isSleepAfterChapterEnabled()) - setSleepEndOfChapters(playbackManager.chaptersUntilSleep, shouldCallUpdateTimer = false) + setSleepEndOfChapters(sleepTimer.state.numberOfChaptersLeft, shouldCallUpdateTimer = false) sleepingInText.postValue(calcSleepingInChaptersText()) } else { isSleepAtEndOfEpisodeOrChapter.postValue(false) - playbackManager.updateSleepTimerStatus(false) + sleepTimer.updateSleepTimerStatus(false) } } - fun timeLeftInSeconds(): Int? { - return sleepTimer.timeLeftInSecs() + fun timeLeftInSeconds(): Int { + return (sleepTimer.state.timeLeft.inWholeMilliseconds / DateUtils.SECOND_IN_MILLIS).toInt() } fun sleepTimerAfter(mins: Int) { + sleepTimer.sleepAfter(mins.toDuration(DurationUnit.MINUTES)) LogBuffer.i(SleepTimer.TAG, "Sleep after $mins minutes configured") - sleepTimer.sleepAfter(duration = mins.toDuration(DurationUnit.MINUTES)) { - playbackManager.updateSleepTimerStatus(sleepTimeRunning = true) - } } fun sleepTimerAfterEpisode(episodes: Int = 1) { LogBuffer.i(SleepTimer.TAG, "Sleep after $episodes episodes configured") settings.setlastSleepEndOfEpisodes(episodes) - playbackManager.updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterEpisodes = episodes) sleepTimer.cancelTimer() + sleepTimer.updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterEpisodes = episodes) } fun sleepTimerAfterChapter(chapters: Int = 1) { LogBuffer.i(SleepTimer.TAG, "Sleep after $chapters chapters configured") settings.setlastSleepEndOfChapters(chapters) - playbackManager.updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterChapters = chapters) sleepTimer.cancelTimer() + sleepTimer.updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterChapters = chapters) } fun cancelSleepTimer() { LogBuffer.i(SleepTimer.TAG, "Cancelled sleep timer") - playbackManager.updateSleepTimerStatus(sleepTimeRunning = false) + sleepTimer.updateSleepTimerStatus(sleepTimeRunning = false) sleepTimer.cancelTimer() } fun sleepTimerAddExtraMins(mins: Int) { - sleepTimer.addExtraTime(mins) + sleepTimer.addExtraTime(mins.toDuration(DurationUnit.MINUTES)) updateSleepTimer() } diff --git a/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModelTest.kt b/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModelTest.kt index 5c0d4a9270a..8d03a0f121b 100644 --- a/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModelTest.kt +++ b/modules/features/player/src/test/java/au/com/shiftyjelly/pocketcasts/player/viewmodel/PlayerViewModelTest.kt @@ -24,6 +24,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.download.DownloadManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackState import au.com.shiftyjelly.pocketcasts.repositories.playback.SleepTimer +import au.com.shiftyjelly.pocketcasts.repositories.playback.SleepTimerState import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue import au.com.shiftyjelly.pocketcasts.repositories.playback.UpNextQueue.State import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager @@ -249,6 +250,7 @@ class PlayerViewModelTest { private fun initViewModel( currentEpisode: BaseEpisode = podcastEpisode, ) { + whenever(sleepTimer.state).thenReturn(SleepTimerState()) whenever(playbackManager.playbackStateRelay).thenReturn(BehaviorRelay.create().toSerialized()) whenever(upNextQueue.currentEpisode).thenReturn(currentEpisode) whenever(playbackManager.upNextQueue).thenReturn(upNextQueue) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index 059b0627adb..d249dc44f71 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -220,15 +220,6 @@ open class PlaybackManager @Inject constructor( bookmarkFeature = bookmarkFeature, ) - var episodesUntilSleep: Int = 0 - - var chaptersUntilSleep: Int = 0 - - private val lastListenedForEndOfChapter = mutableMapOf( - "chapterUuid" to null, - "episodeUuid" to null, - ) - var player: Player? = null val mediaSession: MediaSessionCompat @@ -309,14 +300,6 @@ open class PlaybackManager @Inject constructor( return player?.isStreaming ?: false } - fun updateSleepTimerStatus(sleepTimeRunning: Boolean, sleepAfterEpisodes: Int = 0, sleepAfterChapters: Int = 0) { - this.episodesUntilSleep = sleepAfterEpisodes - this.chaptersUntilSleep = sleepAfterChapters - playbackStateRelay.blockingFirst().let { - playbackStateRelay.accept(it.copy(isSleepTimerRunning = sleepTimeRunning, lastChangeFrom = LastChangeFrom.OnUpdateSleepTimerStatus.value)) - } - } - fun setupProgressSync() { syncTimerDisposable?.dispose() syncTimerDisposable = playbackStateRelay.sample(settings.getPeriodicSaveTimeMs(), TimeUnit.MILLISECONDS) @@ -1463,64 +1446,42 @@ open class PlaybackManager @Inject constructor( } private suspend fun sleepEndOfEpisode(episode: BaseEpisode?) { - if (isSleepAfterEpisodeEnabled()) { - episode?.uuid?.let { sleepTimer.setEndOfEpisodeUuid(it) } - episodesUntilSleep -= 1 - } + if (episode == null) return - if (isSleepAfterEpisodeEnabled()) return + sleepTimer.sleepEndOfEpisode(episode) { + showToast(application.getString(LR.string.player_sleep_time_fired)) - withContext(Dispatchers.Main) { - playbackStateRelay.blockingFirst().let { - playbackStateRelay.accept(it.copy(isSleepTimerRunning = false, lastChangeFrom = LastChangeFrom.OnCompletion.value)) + val podcast = playbackStateRelay.blockingFirst().podcast + if (podcast != null && podcast.skipLastSecs > 0) { + pause(sourceView = SourceView.AUTO_PAUSE) } - } - LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Sleeping playback for end of episode") - - showToast(application.getString(LR.string.player_sleep_time_fired)) - - val podcast = playbackStateRelay.blockingFirst().podcast - if (podcast != null && podcast.skipLastSecs > 0) { - pause(sourceView = SourceView.AUTO_PAUSE) - } - onPlayerPaused() + onPlayerPaused() - // jump back 5 seconds from the current time so when the player opens it doesn't complete before giving the user a chance to skip back - player?.let { - val currentTimeMs = it.getCurrentPositionMs() - 5000 - if (currentTimeMs > 0) { - val currentTimeSecs = currentTimeMs.toDouble() / 1000.0 - episodeManager.updatePlayedUpToBlocking(episode, currentTimeSecs, false) + // jump back 5 seconds from the current time so when the player opens it doesn't complete before giving the user a chance to skip back + player?.let { + val currentTimeMs = it.getCurrentPositionMs() - 5000 + if (currentTimeMs > 0) { + val currentTimeSecs = currentTimeMs.toDouble() / 1000.0 + episodeManager.updatePlayedUpToBlocking(episode, currentTimeSecs, false) + } } - } - stop() + stop() + } } private suspend fun sleepEndOfChapter() { - if (isSleepAfterChapterEnabled()) { - chaptersUntilSleep -= 1 - sleepTimer.setEndOfChapter() - } + sleepTimer.sleepEndOfChapter { + showToast(application.getString(LR.string.player_sleep_time_fired_end_of_chapter)) - if (isSleepAfterChapterEnabled()) return - - withContext(Dispatchers.Main) { - playbackStateRelay.blockingFirst().let { - playbackStateRelay.accept(it.copy(isSleepTimerRunning = false, lastChangeFrom = LastChangeFrom.OnCompletion.value)) + val podcast = playbackStateRelay.blockingFirst().podcast + if (podcast != null && podcast.skipLastSecs > 0) { + pause(sourceView = SourceView.AUTO_PAUSE) } - } - LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Sleeping playback for end of chapters") - - showToast(application.getString(LR.string.player_sleep_time_fired_end_of_chapter)) + onPlayerPaused() - val podcast = playbackStateRelay.blockingFirst().podcast - if (podcast != null && podcast.skipLastSecs > 0) { - pause(sourceView = SourceView.AUTO_PAUSE) + stop() } - onPlayerPaused() - - stop() } private suspend fun showToast(message: String) { @@ -2134,30 +2095,7 @@ open class PlaybackManager @Inject constructor( player?.play(currentTimeMs) - sleepTimer.restartSleepTimerIfApplies( - autoSleepTimerEnabled = settings.autoSleepTimerRestart.value, - currentEpisodeUuid = episode.uuid, - timerState = SleepTimer.SleepTimerState( - isSleepTimerRunning = playbackStateRelay.blockingFirst().isSleepTimerRunning, - isSleepEndOfEpisodeRunning = isSleepAfterEpisodeEnabled(), - isSleepEndOfChapterRunning = isSleepAfterChapterEnabled(), - numberOfEpisodes = settings.getlastSleepEndOfEpisodes(), - numberOfChapters = settings.getlastSleepEndOfChapter(), - ), - onRestartSleepAfterTime = { - updateSleepTimerStatus(sleepTimeRunning = true) - }, - onRestartSleepOnEpisodeEnd = { - val episodes = settings.getlastSleepEndOfEpisodes() - LogBuffer.i(SleepTimer.TAG, "Sleep timer was restarted with end of $episodes episodes set") - updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterEpisodes = episodes) - }, - onRestartSleepOnChapterEnd = { - val chapter = settings.getlastSleepEndOfChapter() - LogBuffer.i(SleepTimer.TAG, "Sleep timer was restarted with end of $chapter chapter set") - updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterChapters = chapter) - }, - ) + sleepTimer.restartSleepTimerIfApplies(currentEpisodeUuid = episode.uuid) trackPlayback(AnalyticsEvent.PLAYBACK_PLAY, sourceView) } @@ -2329,9 +2267,6 @@ open class PlaybackManager @Inject constructor( if (isPlaying()) { statsManager.addTotalListeningTime(UPDATE_TIMER_POLL_TIME) } - if (playbackStateRelay.blockingFirst().isSleepTimerRunning && !isSleepAfterEpisodeEnabled()) { // Does not apply to end of episode sleep time - setupFadeOutWhenFinishingSleepTimer() - } verifySleepTimeForEndOfChapter() } .switchMapCompletable { updateCurrentPositionRx() } @@ -2339,59 +2274,53 @@ open class PlaybackManager @Inject constructor( } private fun verifySleepTimeForEndOfChapter() { - val playbackState = playbackStateRelay.blockingFirst() - val currentChapterUuid = getCurrentChapterUuidForSleepTime(playbackState) + val currentChapterUuid = getCurrentChapterUuid() val currentEpisodeUui = getCurrentEpisode()?.uuid if (!isSleepAfterChapterEnabled()) { - lastListenedForEndOfChapter.setlastListenedChapterUuid(null) - lastListenedForEndOfChapter.setlastListenedEpisodeUuid(null) + updateLastListenedState { copy(chapterUuid = null, episodeUuid = null) } return } - if (lastListenedForEndOfChapter.getlastListenedChapterUuid().isNullOrEmpty()) { - lastListenedForEndOfChapter.setlastListenedChapterUuid(currentChapterUuid) + if (playbackStateRelay.blockingFirst().lastListenedState.chapterUuid.isNullOrEmpty()) { + updateLastListenedState { copy(chapterUuid = currentChapterUuid) } } - if (lastListenedForEndOfChapter.getlastListenedEpisodeUuid().isNullOrEmpty()) { - lastListenedForEndOfChapter.setlastListenedEpisodeUuid(currentEpisodeUui) + if (playbackStateRelay.blockingFirst().lastListenedState.episodeUuid.isNullOrEmpty()) { + updateLastListenedState { copy(episodeUuid = currentEpisodeUui) } } // When we switch from a episode that contains chapters to another one that does not have chapters // the current chapter is null, so for this case we would need to verify if the episode changed to update the sleep timer counter for end of chapter - if (currentChapterUuid == null && !lastListenedForEndOfChapter.getlastListenedEpisodeUuid().isNullOrEmpty() && lastListenedForEndOfChapter.getlastListenedEpisodeUuid() != currentEpisodeUui) { + if (currentChapterUuid.isNullOrEmpty() && !playbackStateRelay.blockingFirst().lastListenedState.episodeUuid.isNullOrEmpty() && playbackStateRelay.blockingFirst().lastListenedState.episodeUuid != currentEpisodeUui) { applicationScope.launch { - lastListenedForEndOfChapter.setlastListenedChapterUuid(null) - lastListenedForEndOfChapter.setlastListenedEpisodeUuid(currentEpisodeUui) + updateLastListenedState { copy(episodeUuid = currentEpisodeUui) } sleepEndOfChapter() } - } else if (lastListenedForEndOfChapter.getlastListenedChapterUuid() == currentChapterUuid) { // Same chapter + } else if (playbackStateRelay.blockingFirst().lastListenedState.chapterUuid == currentChapterUuid) { // Same chapter return } else { // Changed chapter applicationScope.launch { - lastListenedForEndOfChapter.setlastListenedChapterUuid(currentChapterUuid) - lastListenedForEndOfChapter.setlastListenedEpisodeUuid(getCurrentEpisode()?.uuid) + updateLastListenedState { copy(chapterUuid = currentChapterUuid, episodeUuid = getCurrentEpisode()?.uuid) } sleepEndOfChapter() } } } - private fun setupFadeOutWhenFinishingSleepTimer() { - // it needs to run in the main thread because of player getVolume - applicationScope.launch(Dispatchers.Main) { - val timeLeft = sleepTimer.timeLeftInSecs() - timeLeft?.let { - val fadeDuration = 5 - val startVolume = (player as? SimplePlayer)?.getVolume() ?: 1.0f + private fun updateLastListenedState(update: PlaybackState.LastListenedState.() -> PlaybackState.LastListenedState) { + playbackStateRelay.blockingFirst().let { currentState -> + val updatedLastListenedState = currentState.lastListenedState.update() + playbackStateRelay.accept( + currentState.copy( + lastListenedState = updatedLastListenedState, + ), + ) + } + } - if (timeLeft <= fadeDuration) { - val fraction = timeLeft.toFloat() / fadeDuration - val newVolume = startVolume * fraction - player?.setVolume(newVolume) - } else { - player?.setVolume(startVolume) - } - } + fun performVolumeFadeOut(duration: Double) { + player?.let { + PlayerVolumeFadeOut(it, applicationScope).performFadeOut(duration, onStopPlaying = { restorePlayerVolume() }) } } @@ -2573,36 +2502,21 @@ open class PlaybackManager @Inject constructor( this.notificationPermissionChecker = notificationPermissionChecker } - fun isSleepAfterEpisodeEnabled(): Boolean = episodesUntilSleep != 0 + fun isSleepAfterEpisodeEnabled(): Boolean = sleepTimer.state.isSleepEndOfEpisodeRunning - fun isSleepAfterChapterEnabled(): Boolean = chaptersUntilSleep != 0 + fun isSleepAfterChapterEnabled(): Boolean = sleepTimer.state.isSleepEndOfChapterRunning fun restorePlayerVolume() { (player as? SimplePlayer)?.restoreVolume() } - private fun getCurrentChapterUuidForSleepTime(playbackState: PlaybackState): String? { + private fun getCurrentChapterUuid(): String? { + val playbackState = playbackStateRelay.blockingFirst() val currentChapter = playbackState.chapters.getChapter(playbackState.positionMs.milliseconds) return currentChapter?.let { it.title + it.startTime } } - private fun MutableMap.getlastListenedEpisodeUuid(): String? { - return this["episodeUuid"] - } - - private fun MutableMap.getlastListenedChapterUuid(): String? { - return this["chapterUuid"] - } - - private fun MutableMap.setlastListenedEpisodeUuid(value: String?) { - this["episodeUuid"] = value - } - - private fun MutableMap.setlastListenedChapterUuid(value: String?) { - this["chapterUuid"] = value - } - enum class ContentType(val analyticsValue: String) { AUDIO("audio"), VIDEO("video"), diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt index 663d524ce42..c4c2f29be6c 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt @@ -15,10 +15,13 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat +import android.widget.Toast import androidx.media.MediaBrowserServiceCompat import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTracker +import au.com.shiftyjelly.pocketcasts.analytics.SourceView import au.com.shiftyjelly.pocketcasts.localization.BuildConfig +import au.com.shiftyjelly.pocketcasts.localization.R import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode @@ -49,16 +52,29 @@ import au.com.shiftyjelly.pocketcasts.utils.Util import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer import com.jakewharton.rxrelay2.BehaviorRelay import dagger.hilt.android.AndroidEntryPoint +import io.reactivex.Observable import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable import io.reactivex.rxkotlin.Observables import io.reactivex.rxkotlin.addTo import io.reactivex.rxkotlin.subscribeBy +import io.reactivex.schedulers.Schedulers import java.util.Timer import java.util.TimerTask +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.asObservable import kotlinx.coroutines.rx2.awaitSingleOrNull @@ -129,6 +145,8 @@ open class PlaybackService : MediaBrowserServiceCompat(), CoroutineScope { @Inject lateinit var analyticsTracker: AnalyticsTracker + @Inject lateinit var sleepTimer: SleepTimer + var mediaController: MediaControllerCompat? = null set(value) { field = value @@ -144,6 +162,9 @@ open class PlaybackService : MediaBrowserServiceCompat(), CoroutineScope { private val disposables = CompositeDisposable() + private var sleepTimerDisposable: Disposable? = null + private var currentTimeLeft: Duration = ZERO + override val coroutineContext: CoroutineContext get() = Dispatchers.Default @@ -162,12 +183,15 @@ open class PlaybackService : MediaBrowserServiceCompat(), CoroutineScope { mediaController = MediaControllerCompat(this, mediaSession) notificationManager = PlayerNotificationManagerImpl(this) + + observePlaybackState() } override fun onDestroy() { super.onDestroy() disposables.clear() + sleepTimerDisposable?.dispose() LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Playback service destroyed") } @@ -651,4 +675,67 @@ open class PlaybackService : MediaBrowserServiceCompat(), CoroutineScope { // convert podcasts to the media browser format return podcasts.mapNotNull { podcast -> convertPodcastToMediaItem(context = this, podcast = podcast, useEpisodeArtwork = settings.artworkConfiguration.value.useEpisodeArtwork) } } + + private fun observePlaybackState() { + sleepTimer.stateFlow + .map { it } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .onEach { state -> + onSleepTimerStateChange(state) + } + .catch { throwable -> + Timber.e(throwable, "Error observing SleepTimer state") + } + .launchIn(this) + } + + private fun onSleepTimerStateChange(state: SleepTimerState) { + if (state.isSleepTimerRunning && state.timeLeft != ZERO) { + startOrUpdateSleepTimer(state.timeLeft) + } else { + cancelSleepTimer() + } + } + + private fun startOrUpdateSleepTimer(newTimeLeft: Duration) { + if (newTimeLeft == ZERO || newTimeLeft.isNegative()) { + return + } + + if (sleepTimerDisposable == null || sleepTimerDisposable!!.isDisposed) { + currentTimeLeft = newTimeLeft + + sleepTimerDisposable = Observable.interval(1, TimeUnit.SECONDS, Schedulers.computation()) + .takeWhile { currentTimeLeft > ZERO } + .doOnNext { + currentTimeLeft = currentTimeLeft.minus(1.seconds) + sleepTimer.updateSleepTimerStatus(sleepTimeRunning = currentTimeLeft != ZERO, timeLeft = currentTimeLeft) + + if (currentTimeLeft == 5.seconds) { + playbackManager.performVolumeFadeOut(5.0) + } + + if (currentTimeLeft <= ZERO) { + LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Paused from sleep timer.") + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(applicationContext, applicationContext.getString(R.string.player_sleep_timer_stopped_your_podcast), Toast.LENGTH_LONG).show() + playbackManager.restorePlayerVolume() + } + playbackManager.pause(sourceView = SourceView.AUTO_PAUSE) + sleepTimer.updateSleepTimerStatus(sleepTimeRunning = false) + cancelSleepTimer() + } + } + .subscribe() + } else { + currentTimeLeft = newTimeLeft + } + } + + private fun cancelSleepTimer() { + sleepTimerDisposable?.dispose() + sleepTimerDisposable = null + currentTimeLeft = ZERO + } } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackState.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackState.kt index afb01509ca7..1953bc23faa 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackState.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackState.kt @@ -10,7 +10,6 @@ data class PlaybackState( val state: State = State.EMPTY, val isBuffering: Boolean = false, val isPrepared: Boolean = false, - val isSleepTimerRunning: Boolean = false, val title: String = "", val durationMs: Int = -1, val positionMs: Int = 0, @@ -27,8 +26,14 @@ data class PlaybackState( val isVolumeBoosted: Boolean = false, // when transientLoss is true the foreground service won't be stopped val transientLoss: Boolean = false, + val lastListenedState: LastListenedState = LastListenedState(), ) { + data class LastListenedState( + val chapterUuid: String? = null, + val episodeUuid: String? = null, + ) + enum class State { EMPTY, PAUSED, PLAYING, STOPPED, ERROR } @@ -69,7 +74,6 @@ data class PlaybackState( state = state, isBuffering = !episode.isDownloaded && state == State.PLAYING, isPrepared = isPrepared, - isSleepTimerRunning = previousPlaybackState?.isSleepTimerRunning ?: false, title = episode.title, durationMs = episode.durationMs, positionMs = episode.playedUpToMs, @@ -80,6 +84,7 @@ data class PlaybackState( trimMode = playbackEffects.trimMode, isVolumeBoosted = playbackEffects.isVolumeBoosted, lastChangeFrom = lastChangeFrom.value, + lastListenedState = previousPlaybackState?.lastListenedState ?: LastListenedState(), ) } } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlayerVolumeFadeOut.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlayerVolumeFadeOut.kt new file mode 100644 index 00000000000..0fc45d63ee0 --- /dev/null +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlayerVolumeFadeOut.kt @@ -0,0 +1,46 @@ +package au.com.shiftyjelly.pocketcasts.repositories.playback + +import kotlin.math.exp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class PlayerVolumeFadeOut( + private var player: Player, + private val scope: CoroutineScope, +) { + companion object { + private const val VOLUME_CHANGES_PER_SECOND = 30.0 + private const val FADE_VELOCITY = 2.0 + private const val FROM_VOLUME = 1.0 + private const val TO_VOLUME = 0.0 + } + + fun performFadeOut(duration: Double, onStopPlaying: () -> Unit) { + val totalSteps = (duration * VOLUME_CHANGES_PER_SECOND).toInt() + val delayBetweenSteps = (1000 / VOLUME_CHANGES_PER_SECOND).toLong() + + var currentStep = 0 + + scope.launch(Dispatchers.Main) { + while (currentStep < totalSteps) { + val normalizedTime = (currentStep.toDouble() / totalSteps).coerceIn(0.0, 1.0) + val volumeMultiplier = exp(-FADE_VELOCITY * normalizedTime) * (1 - normalizedTime) + val newVolume = TO_VOLUME + (FROM_VOLUME - TO_VOLUME) * volumeMultiplier + + if (player.isPlaying()) { + player.setVolume(newVolume.toFloat()) + } else { + onStopPlaying() + break + } + + currentStep++ + delay(delayBetweenSteps) + } + + player.setVolume(TO_VOLUME.toFloat()) + } + } +} diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimer.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimer.kt index 95685e35f23..d87d4f44ce1 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimer.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimer.kt @@ -1,28 +1,27 @@ package au.com.shiftyjelly.pocketcasts.repositories.playback -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.text.format.DateUtils import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent.PLAYER_SLEEP_TIMER_RESTARTED import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTracker +import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode +import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import dagger.hilt.android.qualifiers.ApplicationContext -import java.util.Calendar import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update @Singleton class SleepTimer @Inject constructor( + private val settings: Settings, private val analyticsTracker: AnalyticsTracker, - @ApplicationContext private val context: Context, ) { companion object { - private val MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES = 5.minutes + private val MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES = 1.minutes private const val TIME_KEY = "time" private const val NUMBER_OF_EPISODES_KEY = "number_of_episodes" private const val NUMBER_OF_CHAPTERS_KEY = "number_of_chapters" @@ -31,171 +30,179 @@ class SleepTimer @Inject constructor( const val TAG: String = "SleepTimer" } - private var sleepTimeMs: Long? = null - private var lastSleepAfterTime: Duration? = null - private var lastSleepAfterEndOfChapterTime: Duration? = null - private var lastTimeSleepTimeHasFinished: Duration? = null - private var lastEpisodeUuidAutomaticEnded: String? = null + private var sleepTimerHistory: SleepTimerHistory = SleepTimerHistory.None - fun sleepAfter(duration: Duration, onSuccess: () -> Unit) { - val sleepAt = System.currentTimeMillis().milliseconds + duration + private val _stateFlow: MutableStateFlow = MutableStateFlow(SleepTimerState()) + val stateFlow: StateFlow = _stateFlow - if (createAlarm(sleepAt.inWholeMilliseconds)) { - lastSleepAfterTime = duration - cancelAutomaticSleepOnEpisodeEndRestart() - cancelAutomaticSleepOnChapterEndRestart() - onSuccess() + val state: SleepTimerState + get() = _stateFlow.value + + fun updateSleepTimerStatus( + sleepTimeRunning: Boolean, + sleepAfterEpisodes: Int = 0, + sleepAfterChapters: Int = 0, + timeLeft: Duration? = null, + ) { + _stateFlow.update { currentState -> + currentState.copy( + isSleepTimerRunning = sleepTimeRunning, + numberOfEpisodesLeft = sleepAfterEpisodes, + numberOfChaptersLeft = sleepAfterChapters, + timeLeft = if (!sleepTimeRunning || sleepAfterEpisodes != 0 || sleepAfterChapters != 0) { + ZERO + } else { + timeLeft ?: currentState.timeLeft + }, + ) } } - fun addExtraTime(minutes: Int) { - val currentTimeMs = sleepTimeMs - if (currentTimeMs == null || currentTimeMs < 0) { + fun sleepAfter(duration: Duration) { + updateSleepTimerStatus(sleepTimeRunning = true, timeLeft = duration) + + sleepTimerHistory = SleepTimerHistory.AfterTime( + lastSleepAfterTime = duration, + lastFinishedTime = System.currentTimeMillis().milliseconds + duration, + ) + } + + fun addExtraTime(duration: Duration) { + val currentTimeLeft: Duration = state.timeLeft + if (currentTimeLeft < ZERO) { return } - val time = Calendar.getInstance().apply { - timeInMillis = currentTimeMs - add(Calendar.MINUTE, minutes) + val newTimeLeft = currentTimeLeft + duration + + _stateFlow.update { currentState -> + currentState.copy(timeLeft = newTimeLeft) } - LogBuffer.i(TAG, "Added extra time: $minutes") - createAlarm(time.timeInMillis) + + LogBuffer.i(TAG, "Added extra time: $newTimeLeft") } - fun restartTimerIfIsRunning(onSuccess: () -> Unit): Duration? { - return if (isSleepAfterTimerRunning) { - lastSleepAfterTime?.let { sleepAfter(it, onSuccess) } - lastSleepAfterTime + /* + * This restart only applies if the sleep timer is set to "sleep in x minutes". + * Other options like "end of chapter" and "end of episode" do not apply. + * */ + fun restartTimerForSleepAfterTime(): Duration? { + return if (state.timeLeft != ZERO && sleepTimerHistory is SleepTimerHistory.AfterTime) { + val sleepAfterTime = (sleepTimerHistory as SleepTimerHistory.AfterTime).lastSleepAfterTime + sleepAfter(sleepAfterTime) + sleepAfterTime } else { null } } fun restartSleepTimerIfApplies( - autoSleepTimerEnabled: Boolean, currentEpisodeUuid: String, - timerState: SleepTimerState, - onRestartSleepAfterTime: () -> Unit, - onRestartSleepOnEpisodeEnd: () -> Unit, - onRestartSleepOnChapterEnd: () -> Unit, ) { - if (!autoSleepTimerEnabled) return - - lastTimeSleepTimeHasFinished?.let { lastTimeHasFinished -> - val diffTime = System.currentTimeMillis().milliseconds - lastTimeHasFinished - - if (shouldRestartSleepEndOfChapter(diffTime, timerState.isSleepEndOfChapterRunning)) { - onRestartSleepOnChapterEnd() - analyticsTracker.track(PLAYER_SLEEP_TIMER_RESTARTED, mapOf(TIME_KEY to END_OF_CHAPTER_VALUE, NUMBER_OF_CHAPTERS_KEY to timerState.numberOfChapters)) - } else if (shouldRestartSleepEndOfEpisode(diffTime, currentEpisodeUuid, timerState.isSleepEndOfEpisodeRunning)) { - onRestartSleepOnEpisodeEnd() - analyticsTracker.track(PLAYER_SLEEP_TIMER_RESTARTED, mapOf(TIME_KEY to END_OF_EPISODE_VALUE, NUMBER_OF_EPISODES_KEY to timerState.numberOfEpisodes)) - } else if (shouldRestartSleepAfterTime(diffTime, timerState.isSleepTimerRunning)) { - lastSleepAfterTime?.let { - analyticsTracker.track(PLAYER_SLEEP_TIMER_RESTARTED, mapOf(TIME_KEY to it.inWholeSeconds)) - LogBuffer.i(TAG, "Was restarted with ${it.inWholeMinutes} minutes set") - sleepAfter(it, onRestartSleepAfterTime) + if (!settings.autoSleepTimerRestart.value || sleepTimerHistory is SleepTimerHistory.None) return + val lastTimeHasFinished = sleepTimerHistory.lastFinishedTime ?: return + + val diffTime = System.currentTimeMillis().milliseconds - lastTimeHasFinished + + when (val history = sleepTimerHistory) { + is SleepTimerHistory.AfterChapter -> { + if (shouldRestartSleepEndOfChapter(diffTime, state.isSleepEndOfChapterRunning)) { + val chapter = settings.getlastSleepEndOfChapter() + LogBuffer.i(TAG, "Sleep timer was restarted with end of $chapter chapter set") + updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterChapters = chapter) + analyticsTracker.track(PLAYER_SLEEP_TIMER_RESTARTED, mapOf(TIME_KEY to END_OF_CHAPTER_VALUE, NUMBER_OF_CHAPTERS_KEY to settings.getlastSleepEndOfChapter())) + } + } + + is SleepTimerHistory.AfterEpisode -> { + if (shouldRestartSleepEndOfEpisode(diffTime, currentEpisodeUuid, state.isSleepEndOfEpisodeRunning, history.lastEpisodeUuidAutomaticEnded)) { + val episodes = settings.getlastSleepEndOfEpisodes() + LogBuffer.i(TAG, "Sleep timer was restarted with end of $episodes episodes set") + updateSleepTimerStatus(sleepTimeRunning = true, sleepAfterEpisodes = episodes) + analyticsTracker.track(PLAYER_SLEEP_TIMER_RESTARTED, mapOf(TIME_KEY to END_OF_EPISODE_VALUE, NUMBER_OF_EPISODES_KEY to settings.getlastSleepEndOfEpisodes())) + } + } + + is SleepTimerHistory.AfterTime -> { + if (shouldRestartSleepAfterTime(diffTime, state.isSleepTimerRunning)) { + sleepAfter(history.lastSleepAfterTime) + analyticsTracker.track(PLAYER_SLEEP_TIMER_RESTARTED, mapOf(TIME_KEY to history.lastSleepAfterTime.inWholeSeconds)) + LogBuffer.i(TAG, "Was restarted with ${history.lastSleepAfterTime.inWholeMinutes} minutes set") } } + + SleepTimerHistory.None -> {} } } - fun setEndOfEpisodeUuid(uuid: String) { - LogBuffer.i(TAG, "Episode $uuid was marked as end of episode") - lastEpisodeUuidAutomaticEnded = uuid - lastTimeSleepTimeHasFinished = System.currentTimeMillis().milliseconds - cancelAutomaticSleepAfterTimeRestart() - cancelAutomaticSleepOnChapterEndRestart() - } + suspend fun sleepEndOfEpisode(episode: BaseEpisode, onSleepEndOfEpisode: suspend () -> Unit) { + if (state.isSleepEndOfEpisodeRunning) { + setEndOfEpisodeUuid(episode.uuid) + updateSleepTimer { + copy(numberOfEpisodesLeft = state.numberOfEpisodesLeft - 1) + } + } - fun setEndOfChapter() { - LogBuffer.i(TAG, "End of chapter was reached") - val time = System.currentTimeMillis().milliseconds - lastSleepAfterEndOfChapterTime = time - lastTimeSleepTimeHasFinished = time - cancelAutomaticSleepAfterTimeRestart() - cancelAutomaticSleepOnEpisodeEndRestart() - } + if (state.isSleepEndOfEpisodeRunning) return - private fun shouldRestartSleepAfterTime(diffTime: Duration, isSleepTimerRunning: Boolean) = diffTime < MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES && lastSleepAfterTime != null && !isSleepTimerRunning + updateSleepTimerStatus(sleepTimeRunning = false) - private fun shouldRestartSleepEndOfEpisode( - diffTime: Duration, - currentEpisodeUuid: String, - isSleepEndOfEpisodeRunning: Boolean, - ) = diffTime < MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES && !lastEpisodeUuidAutomaticEnded.isNullOrEmpty() && currentEpisodeUuid != lastEpisodeUuidAutomaticEnded && !isSleepEndOfEpisodeRunning - - private fun shouldRestartSleepEndOfChapter(diffTime: Duration, isSleepEndOfChapterRunning: Boolean) = diffTime < MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES && !isSleepEndOfChapterRunning && lastSleepAfterEndOfChapterTime != null - - private fun createAlarm(timeMs: Long): Boolean { - val sleepIntent = getSleepIntent() - val alarmManager = getAlarmManager() - alarmManager.cancel(sleepIntent) - return try { - LogBuffer.i(TAG, "Starting...") - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeMs, sleepIntent) - sleepTimeMs = timeMs - lastTimeSleepTimeHasFinished = timeMs.milliseconds - true - } catch (e: Exception) { - LogBuffer.e(LogBuffer.TAG_CRASH, e, "Unable to start sleep timer.") - false - } - } + LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Sleeping playback for end of episode") - fun cancelTimer() { - LogBuffer.i(TAG, "Cleaning automatic sleep timer feature...") - getAlarmManager().cancel(getSleepIntent()) - cancelSleepTime() - cancelAutomaticSleepAfterTimeRestart() - cancelAutomaticSleepOnEpisodeEndRestart() - cancelAutomaticSleepOnChapterEndRestart() + onSleepEndOfEpisode() } - val isSleepAfterTimerRunning: Boolean - get() = System.currentTimeMillis() < (sleepTimeMs ?: -1) + suspend fun sleepEndOfChapter(onSleepEndOfChapter: suspend () -> Unit) { + if (state.isSleepEndOfChapterRunning) { + updateSleepTimer { + copy(numberOfChaptersLeft = state.numberOfChaptersLeft - 1) + } + setEndOfChapter() + } - fun timeLeftInSecs(): Int? { - val sleepTimeMs = sleepTimeMs ?: return null + if (state.isSleepEndOfChapterRunning) return - val timeLeft = sleepTimeMs - System.currentTimeMillis() - if (timeLeft < 0) { - LogBuffer.i(TAG, "Cancelled because time is up") - cancelSleepTime() - return null - } - return (timeLeft / DateUtils.SECOND_IN_MILLIS).toInt() - } + updateSleepTimerStatus(sleepTimeRunning = false) - private fun getSleepIntent(): PendingIntent { - val intent = Intent(context, SleepTimerReceiver::class.java) - return PendingIntent.getBroadcast(context, 234324243, intent, PendingIntent.FLAG_IMMUTABLE) - } + LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Sleeping playback for end of chapters") - private fun getAlarmManager(): AlarmManager { - return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + onSleepEndOfChapter() } - private fun cancelSleepTime() { - sleepTimeMs = null + fun cancelTimer() { + LogBuffer.i(TAG, "Cleaning automatic sleep timer feature...") + updateSleepTimerStatus(sleepTimeRunning = false, sleepAfterChapters = 0, sleepAfterEpisodes = 0) + sleepTimerHistory = SleepTimerHistory.None } - private fun cancelAutomaticSleepAfterTimeRestart() { - lastSleepAfterTime = null + private fun setEndOfEpisodeUuid(uuid: String) { + LogBuffer.i(TAG, "Episode $uuid was marked as end of episode") + sleepTimerHistory = SleepTimerHistory.AfterEpisode( + lastEpisodeUuidAutomaticEnded = uuid, + lastFinishedTime = System.currentTimeMillis().milliseconds, + ) } - private fun cancelAutomaticSleepOnEpisodeEndRestart() { - lastEpisodeUuidAutomaticEnded = null + private fun setEndOfChapter() { + LogBuffer.i(TAG, "End of chapter was reached") + val time = System.currentTimeMillis().milliseconds + sleepTimerHistory = SleepTimerHistory.AfterChapter( + lastSleepAfterEndOfChapterTime = time, + lastFinishedTime = time, + ) } - private fun cancelAutomaticSleepOnChapterEndRestart() { - lastSleepAfterEndOfChapterTime = null - } + private fun shouldRestartSleepAfterTime(diffTime: Duration, isSleepTimerRunning: Boolean): Boolean = diffTime < MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES && sleepTimerHistory is SleepTimerHistory.AfterTime && !isSleepTimerRunning - data class SleepTimerState( - val isSleepTimerRunning: Boolean, - val isSleepEndOfEpisodeRunning: Boolean, - val isSleepEndOfChapterRunning: Boolean, - val numberOfEpisodes: Int, - val numberOfChapters: Int, - ) + private fun shouldRestartSleepEndOfEpisode( + diffTime: Duration, + currentEpisodeUuid: String, + isSleepEndOfEpisodeRunning: Boolean, + lastEpisodeUuidAutomaticEnded: String, + ) = diffTime < MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES && currentEpisodeUuid != lastEpisodeUuidAutomaticEnded && !isSleepEndOfEpisodeRunning + + private fun shouldRestartSleepEndOfChapter(diffTime: Duration, isSleepEndOfChapterRunning: Boolean) = diffTime < MIN_TIME_TO_RESTART_SLEEP_TIMER_IN_MINUTES && !isSleepEndOfChapterRunning + + private fun updateSleepTimer(update: SleepTimerState.() -> SleepTimerState) { + _stateFlow.update { currentState -> currentState.update() } + } } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerReceiver.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerReceiver.kt deleted file mode 100644 index a88b4d43600..00000000000 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerReceiver.kt +++ /dev/null @@ -1,24 +0,0 @@ -package au.com.shiftyjelly.pocketcasts.repositories.playback - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.widget.Toast -import au.com.shiftyjelly.pocketcasts.analytics.SourceView -import au.com.shiftyjelly.pocketcasts.localization.R -import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class SleepTimerReceiver : BroadcastReceiver() { - - @Inject lateinit var playbackManager: PlaybackManager - - override fun onReceive(context: Context, intent: Intent) { - LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Paused from sleep timer.") - Toast.makeText(context, context.getString(R.string.player_sleep_timer_stopped_your_podcast), Toast.LENGTH_LONG).show() - playbackManager.pause(sourceView = SourceView.AUTO_PAUSE) - playbackManager.updateSleepTimerStatus(sleepTimeRunning = false) - } -} diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerRestartWhenShakingDevice.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerRestartWhenShakingDevice.kt index 1e3ce515172..c864311bbd1 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerRestartWhenShakingDevice.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerRestartWhenShakingDevice.kt @@ -64,8 +64,9 @@ class SleepTimerRestartWhenShakingDevice @Inject constructor( private fun onDeviceShaken() { if (settings.shakeToResetSleepTimer.value) { - val time = sleepTimer.restartTimerIfIsRunning onSuccess@{ - playbackManager.updateSleepTimerStatus(sleepTimeRunning = true) + val restartedTime = sleepTimer.restartTimerForSleepAfterTime() + + if (restartedTime != null) { playbackManager.restorePlayerVolume() if (context.isAppForeground()) { @@ -77,10 +78,9 @@ class SleepTimerRestartWhenShakingDevice @Inject constructor( } else { playbackManager.playSleepTimeTone() } - } - time?.let { - LogBuffer.i(SleepTimer.TAG, "Restarted with ${time.inWholeMinutes} minutes set after shaking device") - trackSleepTimeRestart(it) + + LogBuffer.i(SleepTimer.TAG, "Restarted with ${restartedTime.inWholeMinutes} minutes set after shaking device") + trackSleepTimeRestart(restartedTime) } } } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerState.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerState.kt new file mode 100644 index 00000000000..7374087ce31 --- /dev/null +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SleepTimerState.kt @@ -0,0 +1,37 @@ +package au.com.shiftyjelly.pocketcasts.repositories.playback + +import kotlin.time.Duration + +data class SleepTimerState( + val isSleepTimerRunning: Boolean = false, + val timeLeft: Duration = Duration.ZERO, + val numberOfEpisodesLeft: Int = 0, + val numberOfChaptersLeft: Int = 0, +) { + val isSleepEndOfEpisodeRunning: Boolean + get() = numberOfEpisodesLeft != 0 + + val isSleepEndOfChapterRunning: Boolean + get() = numberOfChaptersLeft != 0 +} + +sealed class SleepTimerHistory( + open val lastFinishedTime: Duration?, +) { + data class AfterTime( + val lastSleepAfterTime: Duration, + override val lastFinishedTime: Duration, + ) : SleepTimerHistory(lastFinishedTime = lastFinishedTime) + + data class AfterChapter( + val lastSleepAfterEndOfChapterTime: Duration, + override val lastFinishedTime: Duration, + ) : SleepTimerHistory(lastFinishedTime = lastFinishedTime) + + data class AfterEpisode( + val lastEpisodeUuidAutomaticEnded: String, + override val lastFinishedTime: Duration, + ) : SleepTimerHistory(lastFinishedTime = lastFinishedTime) + + data object None : SleepTimerHistory(lastFinishedTime = null) +}