diff --git a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt index 47bf9f3e03..4d67bfa141 100644 --- a/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt +++ b/core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt @@ -6,11 +6,14 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull import voice.core.common.DispatcherProvider import voice.core.common.MainScope @@ -20,10 +23,10 @@ import voice.core.data.store.SleepTimerPreferenceStore import voice.core.logging.api.Logger import voice.core.playback.PlayerController import voice.core.playback.playstate.PlayStateManager -import voice.core.playback.playstate.PlayStateManager.PlayState.Playing import kotlin.math.max import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @SingleIn(AppScope::class) @@ -95,20 +98,33 @@ class SleepTimerImpl internal constructor( _state.value = SleepTimerState.Disabled playerController.pauseWithRewind(fadeOutDuration) + playStateManager.flow.first { it == PlayStateManager.PlayState.Paused } - val shakeDetected = detectShakeWithTimeout() + val resetRequested = detectResetWithTimeout() playerController.setVolume(1F) - if (shakeDetected) { - Logger.i("Shake detected, resetting timer") + if (resetRequested) { + Logger.i("Reset requested (shake or play button), resetting timer") playerController.play() startCountdown(duration) } } - private suspend fun detectShakeWithTimeout(): Boolean { - Logger.d("Waiting $SHAKE_TO_RESET_TIME for shake...") + private suspend fun detectResetWithTimeout(): Boolean { + Logger.d("Waiting $SHAKE_TO_RESET_TIME for shake or play button...") return withTimeoutOrNull(SHAKE_TO_RESET_TIME) { - shakeDetector.detect() + coroutineScope { + val shakeJob = launch { + shakeDetector.detect() + } + val playJob = launch { + playStateManager.flow.first { it == PlayStateManager.PlayState.Playing } + } + select { + shakeJob.onJoin { } + playJob.onJoin { } + } + coroutineContext.cancelChildren() + } true } ?: false } @@ -123,14 +139,14 @@ class SleepTimerImpl internal constructor( } private suspend fun suspendUntilPlaying() { - if (playStateManager.playState != Playing) { + if (playStateManager.playState != PlayStateManager.PlayState.Playing) { Logger.i("Not playing. Waiting for playback to continue.") - playStateManager.flow.first { it == Playing } + playStateManager.flow.first { it == PlayStateManager.PlayState.Playing } Logger.i("Playback resumed.") } } internal companion object { - val SHAKE_TO_RESET_TIME = 30.seconds + val SHAKE_TO_RESET_TIME = 2.minutes } } diff --git a/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt b/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt index 9312e1ad08..be7b3cc72f 100644 --- a/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt +++ b/core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt @@ -166,6 +166,46 @@ class SleepTimerImplTest { sleepTimer.state.value shouldBe SleepTimerState.Disabled } + @Test + fun `play button resets timer within window`() = testScope.runTest { + val duration = 5.seconds + sleepTimer.enable(SleepTimerMode.TimedWithDuration(duration)) + + // 1) Let the countdown finish and enter the shake window + advanceTimeBy(duration + 1.seconds) + runCurrent() + coVerify(exactly = 1) { playerController.pauseWithRewind(any()) } + sleepTimer.state.value shouldBe SleepTimerState.Disabled + + // 2) Simulate play button press on headphones + playStateManager.playState = PlayStateManager.PlayState.Playing + runCurrent() + + // 3) New countdown should start + sleepTimer.state.value shouldBe SleepTimerState.Enabled.WithDuration(duration) + // and player should be playing + verify(exactly = 1) { playerController.play() } + } + + @Test + fun `play button after window does not reset timer`() = testScope.runTest { + val duration = 5.seconds + sleepTimer.enable(SleepTimerMode.TimedWithDuration(duration)) + + // 1) Let the countdown finish and advance past the window + advanceTimeBy(duration + 1.seconds + SleepTimerImpl.SHAKE_TO_RESET_TIME) + runCurrent() + coVerify(exactly = 1) { playerController.pauseWithRewind(any()) } + sleepTimer.state.value shouldBe SleepTimerState.Disabled + + // 2) Simulate play button press on headphones + playStateManager.playState = PlayStateManager.PlayState.Playing + runCurrent() + + // 3) No new countdown should start + sleepTimer.state.value shouldBe SleepTimerState.Disabled + } + companion object { @BeforeClass