summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt62
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt88
-rw-r--r--app/src/main/java/org/terst/listen/playback/Playhead.kt7
-rw-r--r--app/src/main/java/org/terst/listen/playback/PositionWriter.kt55
-rw-r--r--app/src/main/java/org/terst/listen/playback/SleepTimer.kt78
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt115
-rw-r--r--app/src/test/java/org/terst/listen/playback/PositionWriterTest.kt148
-rw-r--r--app/src/test/java/org/terst/listen/playback/SleepTimerTest.kt112
9 files changed, 664 insertions, 3 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index e2d4a50..0186ab3 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-85.12
+81.69
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)
+ }
+}