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)
+}