diff options
Diffstat (limited to 'app/src')
8 files changed, 663 insertions, 2 deletions
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt b/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt new file mode 100644 index 0000000..ab34c28 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt @@ -0,0 +1,62 @@ +package org.terst.listen.playback + +/** + * The rules governing playback position: when an episode counts as finished, where to resume, + * and when the position is worth writing down. + * + * Kept pure and separate from the Media3 callbacks because where a listener got to is the only + * data in this app that cannot be reconstructed from anywhere else. + */ +class PlaybackProgress( + /** Podcasts end in credits and outros nobody sits through. */ + private val completionThreshold: Float = 0.98f, + /** A floor in absolute terms, because 2% of a four-hour recording is still five minutes. */ + private val minRemainingMs: Long = 30_000, + /** + * The longest stretch of trailing content the percentage is allowed to forgive. Without it, + * 98% of a four-hour recording marks complete with five minutes still to play. + */ + private val maxCreditsMs: Long = 120_000, + private val persistIntervalMs: Long = 5_000, + /** Below this, a resume is indistinguishable from starting over. */ + private val resumeFloorMs: Long = 5_000, +) { + + fun isComplete(positionMs: Long, durationMs: Long?): Boolean { + if (durationMs == null || durationMs <= 0) return false + val remaining = durationMs - positionMs + // Effectively at the end. + if (remaining <= minRemainingMs) return true + // Otherwise past the threshold, but only if what's left is plausibly credits rather + // than content. A percentage on its own scales wrongly with episode length. + return positionMs >= durationMs * completionThreshold && remaining <= maxCreditsMs + } + + /** + * Where to start playing. + * + * A stored position past the end means the media changed under us -- a re-encode, or a feed + * whose declared duration was wrong. Restarting beats seeking past the end and stalling. + */ + fun resumePosition(storedMs: Long, durationMs: Long?): Long = when { + storedMs < resumeFloorMs -> 0 + durationMs == null -> storedMs + storedMs >= durationMs -> 0 + isComplete(storedMs, durationMs) -> 0 + else -> storedMs + } + + /** + * Whether the position has moved enough to write. + * + * Android kills a backgrounded player without warning, so this cannot wait for a clean stop. + * Writing on every tick would hammer the database instead, hence an interval -- with any + * backward jump written immediately, since a seek back is deliberate and losing it is + * exactly the thing a listener notices. + */ + fun shouldPersist(lastWrittenMs: Long?, currentMs: Long): Boolean { + if (lastWrittenMs == null) return true + if (currentMs < lastWrittenMs) return true + return currentMs - lastWrittenMs >= persistIntervalMs + } +} diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt index 5fb39c2..fbdcba2 100644 --- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt +++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt @@ -1,8 +1,23 @@ package org.terst.listen.playback +import android.content.Context +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.terst.listen.data.AppDatabase +import org.terst.listen.data.EpisodeDao +import org.terst.listen.data.EpisodeEntity /** * Hosts the player so audio survives the app leaving the foreground. @@ -15,25 +30,94 @@ class PlaybackService : MediaSessionService() { private var player: ExoPlayer? = null private var session: MediaSession? = null + private var writer: PositionWriter? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun onCreate() { super.onCreate() val exo = ExoPlayer.Builder(this) - // Podcast listening is continuous: keep playing through a transient focus loss - // rather than stopping dead, and duck for navigation prompts. + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_MEDIA) + .build(), + // Duck for navigation prompts rather than stopping dead; spoken word recovers + // from ducking far better than it does from a pause mid-sentence. + /* handleAudioFocus = */ true, + ) .setHandleAudioBecomingNoisy(true) .build() + player = exo session = MediaSession.Builder(this, exo).build() + + val episodes = AppDatabase.get(this).episodes() + writer = PositionWriter(ExoPlayhead(exo), episodes) + startPositionTicker() } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session + /** + * Android calls this when the last controller disconnects with playback stopped. Flushing + * here is the one reliable chance to record the final position before the process goes. + */ + override fun onTaskRemoved(rootIntent: android.content.Intent?) { + flush() + super.onTaskRemoved(rootIntent) + } + override fun onDestroy() { + flush() + scope.cancel() session?.release() player?.release() session = null player = null + writer = null super.onDestroy() } + + private fun startPositionTicker() { + scope.launch { + while (true) { + delay(POSITION_TICK_MS) + if (player?.isPlaying == true) writer?.capture() + } + } + } + + private fun flush() { + val current = writer ?: return + // Launched on the same scope so it is ordered behind any capture already in flight. + scope.launch { current.flush() } + } + + private companion object { + const val POSITION_TICK_MS = 2_000L + } +} + +/** Adapts ExoPlayer to the narrow surface PositionWriter needs. */ +private class ExoPlayhead(private val player: ExoPlayer) : Playhead { + override val episodeId: String? get() = player.currentMediaItem?.mediaId + override val positionMs: Long get() = player.currentPosition + override val isPlaying: Boolean get() = player.isPlaying } + +/** Builds the Media3 item for an episode, preferring a downloaded file over the network. */ +fun EpisodeEntity.toMediaItem(): MediaItem = MediaItem.Builder() + // The episode id, so whatever comes back from the player can be tied to a row. + .setMediaId(id) + .setUri(localPath ?: mediaUrl) + .setMimeType(mediaType) + .build() + +/** Applies a show's speed, falling back to the global default. */ +fun Player.applySpeed(showSpeed: Float?, defaultSpeed: Float) { + val speed = showSpeed ?: defaultSpeed + playbackParameters = PlaybackParameters(speed) +} + +/** Convenience for the UI layer, which has a Context rather than a service. */ +fun playbackDatabase(context: Context): AppDatabase = AppDatabase.get(context) diff --git a/app/src/main/java/org/terst/listen/playback/Playhead.kt b/app/src/main/java/org/terst/listen/playback/Playhead.kt new file mode 100644 index 0000000..478b0b2 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/Playhead.kt @@ -0,0 +1,7 @@ +package org.terst.listen.playback + +interface Playhead { + val episodeId: String? + val positionMs: Long + val isPlaying: Boolean +} diff --git a/app/src/main/java/org/terst/listen/playback/PositionWriter.kt b/app/src/main/java/org/terst/listen/playback/PositionWriter.kt new file mode 100644 index 0000000..da3e408 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/PositionWriter.kt @@ -0,0 +1,55 @@ +package org.terst.listen.playback + +import org.terst.listen.data.EpisodeDao + +/** + * Persists the playback position while audio plays. + * + * Android kills a backgrounded media process without warning, so waiting for a clean stop loses + * the position. Writing on every frame would hammer the database instead. [PlaybackProgress] + * decides when a move is worth recording; this applies it. + * + * Depends on [Playhead] rather than Media3's Player, which is far too large to fake -- and a + * component holding the only irreplaceable data in the app should not be reachable solely from + * an emulator. + */ +class PositionWriter( + private val playhead: Playhead, + private val episodes: EpisodeDao, + private val progress: PlaybackProgress = PlaybackProgress(), +) { + private var lastWrittenMs: Long? = null + private var lastEpisodeId: String? = null + + /** Called on a timer while playing. Honours the write interval. */ + suspend fun capture() { + val episodeId = playhead.episodeId ?: return + val position = playhead.positionMs + + // A different episode resets the threshold: otherwise a new episode's early position + // looks like a small move from the previous one's and never gets written. + if (episodeId != lastEpisodeId) { + lastEpisodeId = episodeId + lastWrittenMs = null + } + + if (!progress.shouldPersist(lastWrittenMs, position)) return + write(episodeId, position) + } + + /** + * Called on lifecycle transitions, where there may be no further tick. + * + * Ignores the interval deliberately: the next thing that happens may be the process ending. + */ + suspend fun flush() { + val episodeId = playhead.episodeId ?: return + write(episodeId, playhead.positionMs) + lastEpisodeId = episodeId + } + + private suspend fun write(episodeId: String, positionMs: Long) { + episodes.updatePosition(episodeId, positionMs) + lastWrittenMs = positionMs + } +} diff --git a/app/src/main/java/org/terst/listen/playback/SleepTimer.kt b/app/src/main/java/org/terst/listen/playback/SleepTimer.kt new file mode 100644 index 0000000..d94b371 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/SleepTimer.kt @@ -0,0 +1,78 @@ +package org.terst.listen.playback + +/** + * Sleep timer, in two modes: a countdown, or stop at the end of the current episode. + * + * Whoever set this is falling asleep, which makes the failure modes lopsided. Stopping a little + * late costs nothing. A hard cut at full volume wakes them, and a timer that silently never + * fires leaves audio running all night. So it fades out, and it is driven by wall-clock + * deadlines rather than a tick count that a suspended process would lose. + */ +class SleepTimer(private val fadeMs: Long = 20_000) { + + /** What the timer is waiting for. */ + sealed interface State { + data class Countdown(val remainingMs: Long) : State + data object EndOfEpisode : State + } + + private var deadlineMs: Long? = null + private var endOfEpisode = false + + val isArmed: Boolean get() = deadlineMs != null || endOfEpisode + + val shouldStopAtEpisodeEnd: Boolean get() = endOfEpisode + + fun armFor(durationMs: Long, nowMs: Long) { + deadlineMs = nowMs + durationMs + endOfEpisode = false + } + + fun armForEndOfEpisode() { + deadlineMs = null + endOfEpisode = true + } + + /** + * Adds to whatever is left, rather than resetting to the new amount. + * + * "+5 minutes" on a timer with 30 seconds to run means five and a half minutes. Resetting + * would silently take those 30 seconds away, and this is a control people reach for + * half-asleep without watching the result. + */ + fun extendBy(extraMs: Long, nowMs: Long) { + val remaining = remainingMs(nowMs) ?: 0 + deadlineMs = nowMs + remaining + extraMs + endOfEpisode = false + } + + fun cancel() { + deadlineMs = null + endOfEpisode = false + } + + /** Null in end-of-episode mode, where there is no countdown to show. */ + fun remainingMs(nowMs: Long): Long? { + val deadline = deadlineMs ?: return null + return (deadline - nowMs).coerceAtLeast(0) + } + + fun hasExpired(nowMs: Long): Boolean { + val deadline = deadlineMs ?: return false + return nowMs >= deadline + } + + /** Ramps linearly to silence over the final stretch, so the audio recedes rather than stops. */ + fun volumeAt(nowMs: Long): Float { + val remaining = remainingMs(nowMs) ?: return 1.0f + if (remaining >= fadeMs) return 1.0f + if (fadeMs <= 0) return 0.0f + return (remaining.toFloat() / fadeMs).coerceIn(0.0f, 1.0f) + } + + fun state(nowMs: Long): State? = when { + endOfEpisode -> State.EndOfEpisode + deadlineMs != null -> State.Countdown(remainingMs(nowMs) ?: 0) + else -> null + } +} diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt new file mode 100644 index 0000000..94749a0 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt @@ -0,0 +1,115 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Where a listener got to is the app's only irreplaceable data, so the rules around it are pure + * and tested directly rather than tangled into the Media3 callbacks. + */ +class PlaybackProgressTest { + + private val minute = 60_000L + private val progress = PlaybackProgress(completionThreshold = 0.98f, minRemainingMs = 30_000) + + @Test + fun `an episode played to the end is complete`() { + assertTrue(progress.isComplete(positionMs = 60 * minute, durationMs = 60 * minute)) + } + + /** + * Podcasts end with credits and outros nobody listens through. Requiring the literal last + * millisecond means episodes never mark played, which breaks retention and the queue. + */ + @Test + fun `an episode inside the completion threshold is complete`() { + assertTrue(progress.isComplete(positionMs = 59 * minute, durationMs = 60 * minute)) + } + + @Test + fun `an episode with real time left is not complete`() { + assertFalse(progress.isComplete(positionMs = 30 * minute, durationMs = 60 * minute)) + } + + /** + * A percentage alone misjudges long recordings: 98% of a four-hour episode still leaves five + * minutes of content. The absolute floor catches that. + */ + @Test + fun `a long episode needs an absolute amount remaining`() { + val fourHours = 4 * 60 * minute + assertFalse( + "98% of four hours still has minutes left", + progress.isComplete(positionMs = (fourHours * 0.98).toLong(), durationMs = fourHours), + ) + assertTrue(progress.isComplete(positionMs = fourHours - 10_000, durationMs = fourHours)) + } + + @Test + fun `an unknown duration is never complete`() { + assertFalse(progress.isComplete(positionMs = 5 * minute, durationMs = null)) + assertFalse(progress.isComplete(positionMs = 5 * minute, durationMs = 0)) + } + + // --- resume ---------------------------------------------------------------------------- + + @Test + fun `resumes from the stored position`() { + assertEquals(10 * minute, progress.resumePosition(storedMs = 10 * minute, durationMs = 60 * minute)) + } + + /** A position at the very start is not worth resuming into. */ + @Test + fun `a position near the start resumes from the beginning`() { + assertEquals(0L, progress.resumePosition(storedMs = 1_500, durationMs = 60 * minute)) + } + + /** + * A stored position past the end means the duration changed under us -- a re-encode, or a + * feed that lied. Starting over beats seeking past the end and stalling. + */ + @Test + fun `a position beyond the end restarts`() { + assertEquals(0L, progress.resumePosition(storedMs = 90 * minute, durationMs = 60 * minute)) + } + + @Test + fun `a completed position restarts rather than resuming at the credits`() { + assertEquals(0L, progress.resumePosition(storedMs = 60 * minute, durationMs = 60 * minute)) + } + + @Test + fun `an unknown duration still resumes`() { + assertEquals(10 * minute, progress.resumePosition(storedMs = 10 * minute, durationMs = null)) + } + + // --- writes ---------------------------------------------------------------------------- + + /** + * Position has to survive process death, and Android kills a backgrounded player without + * warning. Writing on every tick would hammer the database, so the rule is: write when it + * has moved enough to matter. + */ + @Test + fun `a small movement does not warrant a write`() { + assertFalse(progress.shouldPersist(lastWrittenMs = 60_000, currentMs = 61_000)) + } + + @Test + fun `a movement past the interval warrants a write`() { + assertTrue(progress.shouldPersist(lastWrittenMs = 60_000, currentMs = 66_000)) + } + + /** Seeking backwards is deliberate and must be recorded immediately. */ + @Test + fun `any backward jump warrants a write`() { + assertTrue(progress.shouldPersist(lastWrittenMs = 60_000, currentMs = 58_000)) + } + + @Test + fun `the first write always happens`() { + assertTrue(progress.shouldPersist(lastWrittenMs = null, currentMs = 1_000)) + } +} diff --git a/app/src/test/java/org/terst/listen/playback/PositionWriterTest.kt b/app/src/test/java/org/terst/listen/playback/PositionWriterTest.kt new file mode 100644 index 0000000..587aeef --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/PositionWriterTest.kt @@ -0,0 +1,148 @@ +package org.terst.listen.playback + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.FakeEpisodeDao + +/** + * Position persistence, tested against a narrow playhead abstraction rather than Media3's Player + * interface -- which is far too large to fake, and is why this logic would otherwise only be + * reachable from an emulator. + */ +class PositionWriterTest { + + private class FakePlayhead( + override var episodeId: String? = "ep-1", + override var positionMs: Long = 0, + override var isPlaying: Boolean = true, + ) : Playhead + + private val dao = FakeEpisodeDao().apply { + rows += EpisodeEntity( + id = "ep-1", + subscriptionId = "s1", + guid = "g1", + title = "Episode", + mediaUrl = "https://cdn/e.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + ) + } + + private fun stored() = dao.rows.single().positionMs + + @Test + fun `records the position once it has moved enough`() = runTest { + val playhead = FakePlayhead(positionMs = 30_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + assertEquals(30_000L, stored()) + } + + @Test + fun `does not write while the position barely moves`() = runTest { + val playhead = FakePlayhead(positionMs = 30_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + + playhead.positionMs = 31_000 + writer.capture() + assertEquals("a one-second move should not warrant a write", 30_000L, stored()) + } + + @Test + fun `writes again once past the interval`() = runTest { + val playhead = FakePlayhead(positionMs = 30_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + + playhead.positionMs = 40_000 + writer.capture() + assertEquals(40_000L, stored()) + } + + /** A seek back is deliberate, and losing it is exactly what a listener notices. */ + @Test + fun `writes immediately on a backward seek`() = runTest { + val playhead = FakePlayhead(positionMs = 60_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + + playhead.positionMs = 59_000 + writer.capture() + assertEquals(59_000L, stored()) + } + + /** The process can die at any moment, so a lifecycle flush ignores the interval. */ + @Test + fun `flush writes regardless of how little it moved`() = runTest { + val playhead = FakePlayhead(positionMs = 30_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + + playhead.positionMs = 30_500 + writer.flush() + assertEquals(30_500L, stored()) + } + + @Test + fun `nothing is written when no episode is loaded`() = runTest { + val writer = PositionWriter(FakePlayhead(episodeId = null, positionMs = 50_000), dao) + writer.capture() + writer.flush() + assertEquals(0L, stored()) + } + + /** An episode removed while playing must not resurrect a row or throw. */ + @Test + fun `an unknown episode id is harmless`() = runTest { + val writer = PositionWriter(FakePlayhead(episodeId = "gone", positionMs = 50_000), dao) + writer.capture() + assertEquals(1, dao.rows.size) + assertEquals(0L, stored()) + } + + @Test + fun `switching episode writes against the right row`() = runTest { + dao.rows += dao.rows.single().copy(id = "ep-2", guid = "g2", positionMs = 0) + val playhead = FakePlayhead(positionMs = 30_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + + playhead.episodeId = "ep-2" + playhead.positionMs = 5_000 + writer.flush() + + assertEquals(30_000L, dao.rows.first { it.id == "ep-1" }.positionMs) + assertEquals(5_000L, dao.rows.first { it.id == "ep-2" }.positionMs) + } + + /** + * Switching episodes resets the write threshold. Otherwise the new episode's early position + * looks like a tiny move from the old one's and goes unrecorded. + */ + @Test + fun `a new episode is written even at a low position`() = runTest { + dao.rows += dao.rows.single().copy(id = "ep-2", guid = "g2", positionMs = 0) + val playhead = FakePlayhead(positionMs = 600_000) + val writer = PositionWriter(playhead, dao) + writer.capture() + + playhead.episodeId = "ep-2" + playhead.positionMs = 1_000 + writer.capture() + + assertEquals("a fresh episode must not inherit the old write threshold", + 1_000L, dao.rows.first { it.id == "ep-2" }.positionMs) + } + + @Test + fun `paused playback still flushes`() = runTest { + val playhead = FakePlayhead(positionMs = 45_000, isPlaying = false) + PositionWriter(playhead, dao).flush() + assertTrue(stored() == 45_000L) + } +} diff --git a/app/src/test/java/org/terst/listen/playback/SleepTimerTest.kt b/app/src/test/java/org/terst/listen/playback/SleepTimerTest.kt new file mode 100644 index 0000000..23649f7 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/SleepTimerTest.kt @@ -0,0 +1,112 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A sleep timer is used by someone falling asleep, so the failure modes are asymmetric: stopping + * slightly late costs nothing, and a hard cut or a stall that never stops wakes them up. + */ +class SleepTimerTest { + + private val minute = 60_000L + private val timer = SleepTimer(fadeMs = 20_000) + + @Test + fun `is off by default`() { + assertFalse(timer.isArmed) + assertNull(timer.state(nowMs = 0)) + } + + @Test + fun `arming for a duration sets a deadline`() { + timer.armFor(30 * minute, nowMs = 1_000) + assertTrue(timer.isArmed) + assertEquals(30 * minute, timer.remainingMs(nowMs = 1_000)) + } + + @Test + fun `remaining time counts down`() { + timer.armFor(30 * minute, nowMs = 0) + assertEquals(20 * minute, timer.remainingMs(nowMs = 10 * minute)) + } + + @Test + fun `remaining never goes negative`() { + timer.armFor(minute, nowMs = 0) + assertEquals(0L, timer.remainingMs(nowMs = 10 * minute)) + } + + @Test + fun `expires once the deadline passes`() { + timer.armFor(minute, nowMs = 0) + assertFalse(timer.hasExpired(nowMs = minute - 1)) + assertTrue(timer.hasExpired(nowMs = minute)) + } + + /** A hard stop at full volume is what wakes people up. */ + @Test + fun `volume fades over the last stretch`() { + timer.armFor(minute, nowMs = 0) + assertEquals(1.0f, timer.volumeAt(nowMs = 0), 0.001f) + assertEquals(1.0f, timer.volumeAt(nowMs = 30_000), 0.001f) + assertEquals(0.5f, timer.volumeAt(nowMs = 50_000), 0.05f) + assertEquals(0.0f, timer.volumeAt(nowMs = minute), 0.001f) + } + + @Test + fun `volume is full when not armed`() { + assertEquals(1.0f, timer.volumeAt(nowMs = 12345), 0.001f) + } + + @Test + fun `cancelling disarms and restores volume`() { + timer.armFor(minute, nowMs = 0) + timer.cancel() + assertFalse(timer.isArmed) + assertEquals(1.0f, timer.volumeAt(nowMs = 59_000), 0.001f) + } + + /** "+5 minutes" on a timer with 30 seconds left means five and a half, not five. */ + @Test + fun `extending adds to the time remaining`() { + timer.armFor(minute, nowMs = 0) + timer.extendBy(5 * minute, nowMs = 30_000) + assertEquals(5 * minute + 30_000, timer.remainingMs(nowMs = 30_000)) + } + + @Test + fun `extending an unarmed timer arms it`() { + timer.extendBy(5 * minute, nowMs = 1_000) + assertTrue(timer.isArmed) + assertEquals(5 * minute, timer.remainingMs(nowMs = 1_000)) + } + + /** + * End-of-episode is a different mode: there is no deadline, so it must not report a + * countdown or start fading. + */ + @Test + fun `end of episode mode has no countdown`() { + timer.armForEndOfEpisode() + assertTrue(timer.isArmed) + assertNull(timer.remainingMs(nowMs = 0)) + assertFalse(timer.hasExpired(nowMs = Long.MAX_VALUE)) + assertEquals(1.0f, timer.volumeAt(nowMs = Long.MAX_VALUE), 0.001f) + } + + @Test + fun `end of episode expires when the episode finishes`() { + timer.armForEndOfEpisode() + assertTrue(timer.shouldStopAtEpisodeEnd) + } + + @Test + fun `a duration timer does not stop at the episode end`() { + timer.armFor(minute, nowMs = 0) + assertFalse(timer.shouldStopAtEpisodeEnd) + } +} |
