summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeLabels.kt53
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt46
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt16
-rw-r--r--app/src/main/java/org/terst/listen/ui/TimeText.kt41
-rw-r--r--app/src/test/java/org/terst/listen/ui/ClockTextTest.kt47
-rw-r--r--app/src/test/java/org/terst/listen/ui/EpisodeProgressLabelTest.kt116
6 files changed, 257 insertions, 62 deletions
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeLabels.kt b/app/src/main/java/org/terst/listen/ui/EpisodeLabels.kt
new file mode 100644
index 0000000..921fec2
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeLabels.kt
@@ -0,0 +1,53 @@
+package org.terst.listen.ui
+
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.playbackDurationMs
+import org.terst.listen.download.WaitingForServerText
+import java.util.concurrent.TimeUnit
+
+/**
+ * The one line under an episode's title, wherever an episode is listed.
+ *
+ * Read in three places that look nothing alike -- a show's list, the episode screen, and the
+ * queue -- which is why it is here rather than in `EpisodeRow.kt`, where it was. It is the
+ * app's most-read sentence, so its order is fixed: when it is from, how long it is, how far in,
+ * then anything stopping it playing.
+ */
+
+/** Duration, how far in, and download state in one line. */
+internal fun EpisodeEntity.progressLabel(): String {
+ val parts = mutableListOf<String>()
+ publishedAtMs?.let { parts += formatReleaseDate(it) }
+ // The measured length where it is known: the feed's is minutes short on any show using
+ // dynamic ad insertion, which is how Peter spotted the list and the player disagreeing.
+ playbackDurationMs?.let { parts += formatDuration(it) }
+ if (positionMs > 0) parts += "${formatDuration(positionMs)} in"
+ if (playedAtMs != null) parts += "played"
+ if (downloadState == DownloadState.FAILED) parts += "download failed"
+ if (downloadState == DownloadState.WAITING_FOR_SERVER) parts += WaitingForServerText.ROW_LABEL
+ return parts.joinToString(" · ")
+}
+
+/**
+ * Relative for the last week, then absolute.
+ *
+ * "3d ago" answers "is this the new one?" at a glance, which is the question in an episode list;
+ * past a week the actual date is more use than a growing day count. A feed dating an episode in
+ * the future gets the date too -- a negative age floors to zero days, which would otherwise read
+ * "today" for as long as the episode existed.
+ */
+private fun formatReleaseDate(publishedAtMs: Long): String {
+ val ageMs = System.currentTimeMillis() - publishedAtMs
+ val days = TimeUnit.MILLISECONDS.toDays(ageMs)
+ return when {
+ ageMs < 0 -> dateFormat.format(java.util.Date(publishedAtMs))
+ days == 0L -> "today"
+ days == 1L -> "yesterday"
+ days < DAYS_BEFORE_ABSOLUTE -> "${days}d ago"
+ else -> dateFormat.format(java.util.Date(publishedAtMs))
+ }
+}
+
+private const val DAYS_BEFORE_ABSOLUTE = 7L
+private val dateFormat = java.text.SimpleDateFormat("d MMM yyyy", java.util.Locale.getDefault())
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
index dac7902..45ed36b 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -32,9 +32,6 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
-import org.terst.listen.download.WaitingForServerText
-import org.terst.listen.data.playbackDurationMs
-import java.util.concurrent.TimeUnit
/**
* One episode and its actions.
@@ -132,46 +129,3 @@ internal fun DownloadIcon(state: DownloadState, onDownload: () -> Unit) {
else -> ActionIcon(Icons.Default.Download, "Download", onDownload)
}
}
-
-/** Duration, how far in, and download state in one line. */
-internal fun EpisodeEntity.progressLabel(): String {
- val parts = mutableListOf<String>()
- publishedAtMs?.let { parts += formatReleaseDate(it) }
- // The measured length where it is known: the feed's is minutes short on any show using
- // dynamic ad insertion, which is how Peter spotted the list and the player disagreeing.
- playbackDurationMs?.let { parts += formatDuration(it) }
- if (positionMs > 0) parts += "${formatDuration(positionMs)} in"
- if (playedAtMs != null) parts += "played"
- if (downloadState == DownloadState.FAILED) parts += "download failed"
- if (downloadState == DownloadState.WAITING_FOR_SERVER) parts += WaitingForServerText.ROW_LABEL
- return parts.joinToString(" · ")
-}
-
-/**
- * Relative for the last week, then absolute.
- *
- * "3d ago" answers "is this the new one?" at a glance, which is the question in an episode list;
- * past a week the actual date is more use than a growing day count.
- */
-private fun formatReleaseDate(publishedAtMs: Long): String {
- val ageMs = System.currentTimeMillis() - publishedAtMs
- val days = TimeUnit.MILLISECONDS.toDays(ageMs)
- return when {
- ageMs < 0 -> dateFormat.format(java.util.Date(publishedAtMs))
- days == 0L -> "today"
- days == 1L -> "yesterday"
- days < DAYS_BEFORE_ABSOLUTE -> "${days}d ago"
- else -> dateFormat.format(java.util.Date(publishedAtMs))
- }
-}
-
-private const val DAYS_BEFORE_ABSOLUTE = 7L
-private val dateFormat = java.text.SimpleDateFormat("d MMM yyyy", java.util.Locale.getDefault())
-
-private const val MINUTES_PER_HOUR = 60
-
-private fun formatDuration(ms: Long): String {
- val hours = TimeUnit.MILLISECONDS.toHours(ms)
- val minutes = TimeUnit.MILLISECONDS.toMinutes(ms) % MINUTES_PER_HOUR
- return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
-}
diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
index 482fa86..c9960ef 100644
--- a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
@@ -88,22 +88,6 @@ fun NowPlayingBar(state: NowPlaying, actions: NowPlayingActions) {
}
}
-private const val SECONDS_PER_MINUTE = 60
-private const val MINUTES_PER_HOUR = 60
-private const val MS_PER_SECOND = 1000
-
-internal fun formatClock(ms: Long): String {
- val totalSeconds = ms / MS_PER_SECOND
- val seconds = totalSeconds % SECONDS_PER_MINUTE
- val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR
- val hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)
- return if (hours > 0) {
- "%d:%02d:%02d".format(hours, minutes, seconds)
- } else {
- "%d:%02d".format(minutes, seconds)
- }
-}
-
/** Title and show, tappable to open the player. */
@Composable
private fun NowPlayingText(state: NowPlaying, modifier: Modifier, onOpen: () -> Unit) {
diff --git a/app/src/main/java/org/terst/listen/ui/TimeText.kt b/app/src/main/java/org/terst/listen/ui/TimeText.kt
new file mode 100644
index 0000000..f70f80f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/TimeText.kt
@@ -0,0 +1,41 @@
+package org.terst.listen.ui
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Milliseconds as the two kinds of time a listener reads.
+ *
+ * [formatClock] is a position -- where you are, or where a break edge is -- so it keeps seconds
+ * and reads like a transport: `1:02:03`. [formatDuration] is a length -- how long a thing is --
+ * where seconds are noise and `1h 2m` is what answers "have I got time for this?".
+ *
+ * They lived in `NowPlayingBar.kt` and `EpisodeRow.kt`, the views that happened to want them
+ * first, and had grown five and three callers respectively. Both are pure, both are read on
+ * screens that share no code, and each file had defined its own minutes-per-hour -- so editing
+ * the now-playing bar meant editing the transcript gutter's formatter. One home, one set of
+ * constants, tested directly: the same arrangement as [SpeedChoices], [DescriptionPreview] and
+ * `WaitingForServerText`, which is already this app's rule for anything it says out loud.
+ */
+private const val MS_PER_SECOND = 1000
+private const val SECONDS_PER_MINUTE = 60
+private const val MINUTES_PER_HOUR = 60
+
+/** A position on the transport: `0:06`, `10:12`, `1:02:03`. Hours appear only once there are any. */
+internal fun formatClock(ms: Long): String {
+ val totalSeconds = ms / MS_PER_SECOND
+ val seconds = totalSeconds % SECONDS_PER_MINUTE
+ val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR
+ val hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)
+ return if (hours > 0) {
+ "%d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%d:%02d".format(minutes, seconds)
+ }
+}
+
+/** A length, rounded to the minute: `45m`, `1h 2m`. */
+internal fun formatDuration(ms: Long): String {
+ val hours = TimeUnit.MILLISECONDS.toHours(ms)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(ms) % MINUTES_PER_HOUR
+ return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
+}
diff --git a/app/src/test/java/org/terst/listen/ui/ClockTextTest.kt b/app/src/test/java/org/terst/listen/ui/ClockTextTest.kt
new file mode 100644
index 0000000..7b589d2
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/ClockTextTest.kt
@@ -0,0 +1,47 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * The timestamp five screens read: the transport's elapsed and remaining, the timeline's break
+ * edges, a chapter's start in the overflow menu, the sleep timer's countdown, and the transcript
+ * gutter's time per line.
+ *
+ * It had no test until now, which is why the hour boundary is pinned from both sides. Everything
+ * in this library is under an hour except the thing this app exists for -- Hardcore History runs
+ * past four -- so the case most likely to be wrong is the one least likely to be noticed.
+ */
+class ClockTextTest {
+
+ @Test
+ fun `under a minute still shows the minute`() {
+ assertEquals("0:00", formatClock(0))
+ assertEquals("0:06", formatClock(6_000))
+ }
+
+ @Test
+ fun `seconds are two digits so the column does not jump`() {
+ assertEquals("0:09", formatClock(9_000))
+ assertEquals("0:10", formatClock(10_000))
+ assertEquals("10:12", formatClock(612_000))
+ }
+
+ /** A partial second belongs to the second it is in, not the next one. */
+ @Test
+ fun `part of a second is not rounded up`() {
+ assertEquals("0:01", formatClock(1_999))
+ }
+
+ @Test
+ fun `an hour appears only once there is one`() {
+ assertEquals("59:59", formatClock(3_599_000))
+ assertEquals("1:00:00", formatClock(3_600_000))
+ }
+
+ @Test
+ fun `past the hour the minutes restart rather than accumulating`() {
+ assertEquals("1:02:03", formatClock(3_723_000))
+ assertEquals("4:05:06", formatClock(14_706_000))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeProgressLabelTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeProgressLabelTest.kt
new file mode 100644
index 0000000..8ad5d8f
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/EpisodeProgressLabelTest.kt
@@ -0,0 +1,116 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.download.WaitingForServerText
+import java.util.concurrent.TimeUnit
+
+/**
+ * The one line under every episode title -- in a show's list, on the episode screen, and in the
+ * queue.
+ *
+ * Untested until now, though it is the most-read sentence in the app. The cases pinned here are
+ * the ones a reader has to be able to tell apart at a glance: how long it is, how far in, and
+ * whether it is going to play at all.
+ */
+class EpisodeProgressLabelTest {
+
+ private fun episode(
+ durationMs: Long? = null,
+ publishedAtMs: Long? = null,
+ positionMs: Long = 0,
+ playedAtMs: Long? = null,
+ state: DownloadState = DownloadState.NONE,
+ ) = EpisodeEntity(
+ id = "e1", subscriptionId = "s1", guid = "g", title = "One", mediaUrl = "https://cdn/1.mp3",
+ mediaType = "audio/mpeg", discoveredAtMs = 0, durationMs = durationMs,
+ publishedAtMs = publishedAtMs, positionMs = positionMs, playedAtMs = playedAtMs,
+ downloadState = state,
+ )
+
+ private fun daysAgo(days: Long) = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days)
+
+ @Test
+ fun `an episode with nothing known of it says nothing`() {
+ assertEquals("", episode().progressLabel())
+ }
+
+ @Test
+ fun `an hour or more reads in hours and minutes`() {
+ assertEquals("45m", episode(durationMs = 2_700_000).progressLabel())
+ assertEquals("1h 2m", episode(durationMs = 3_720_000).progressLabel())
+ }
+
+ /**
+ * The measured length wins over the feed's, which is the whole reason the two are kept apart:
+ * on a show using dynamic ad insertion the feed's figure is minutes short.
+ */
+ @Test
+ fun `the measured length is preferred to the declared one`() {
+ val measured = episode(durationMs = 1_325_000).copy(actualDurationMs = 1_698_000)
+ assertEquals("28m", measured.progressLabel())
+ }
+
+ @Test
+ fun `how far in is shown only once there is somewhere to resume from`() {
+ assertEquals("45m", episode(durationMs = 2_700_000).progressLabel())
+ assertEquals("45m · 12m in", episode(durationMs = 2_700_000, positionMs = 720_000).progressLabel())
+ }
+
+ @Test
+ fun `a finished episode says so`() {
+ assertEquals("45m · played", episode(durationMs = 2_700_000, playedAtMs = 1).progressLabel())
+ }
+
+ @Test
+ fun `a failed download says so, so the row explains why nothing plays`() {
+ assertEquals("download failed", episode(state = DownloadState.FAILED).progressLabel())
+ }
+
+ @Test
+ fun `an episode waiting on the server uses the same words the episode screen does`() {
+ assertEquals(
+ WaitingForServerText.ROW_LABEL,
+ episode(state = DownloadState.WAITING_FOR_SERVER).progressLabel(),
+ )
+ }
+
+ /** "is this the new one?" is the question an episode list is answering. */
+ @Test
+ fun `the first week reads as an age rather than a date`() {
+ assertEquals("today", episode(publishedAtMs = daysAgo(0)).progressLabel())
+ assertEquals("yesterday", episode(publishedAtMs = daysAgo(1)).progressLabel())
+ assertEquals("3d ago", episode(publishedAtMs = daysAgo(3)).progressLabel())
+ assertEquals("6d ago", episode(publishedAtMs = daysAgo(6)).progressLabel())
+ }
+
+ /** Past a week a growing day count is less use than the date itself. */
+ @Test
+ fun `past a week it reads as a date`() {
+ assertEquals("1 Jan 2020", episode(publishedAtMs = 1_577_880_000_000).progressLabel())
+ }
+
+ /**
+ * A feed dating an episode in the future would otherwise read "today" forever, because a
+ * negative age floors to zero days.
+ */
+ @Test
+ fun `a future date reads as a date rather than as today`() {
+ val label = episode(publishedAtMs = daysAgo(-30)).progressLabel()
+ assertEquals(false, label == "today")
+ assertEquals(true, label.contains(" "))
+ }
+
+ @Test
+ fun `the parts appear in the order a reader scans them`() {
+ val label = episode(
+ durationMs = 2_700_000,
+ publishedAtMs = daysAgo(2),
+ positionMs = 720_000,
+ playedAtMs = 1,
+ ).progressLabel()
+ assertEquals("2d ago · 45m · 12m in · played", label)
+ }
+}