summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-25 07:23:54 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-25 07:23:54 +0000
commite1ccfbfb2f5445fa0df66d8416d3eafe061430eb (patch)
treec37cd043c77831624e11420d5f1bafbbb0bdb901 /app/src
parentf5bf05bbc0480a7edcd098a8e40c6c16af5d5f68 (diff)
Library and settings, with less in them
Step 4 of the UI redesign, and the last of it. The library is one list rather than a list plus a card. Up Next is a heading and a row, lining up with the rows under Shows instead of sitting in a bordered box above them. A show row is a dot, a title and a count: forty-three shows is a list to scan, not a shelf to browse, and the one thing worth seeing per row is whether there is anything in it. The count is new -- the query had already counted it to do the ordering and was collapsing it to a boolean on the way out, so the screen said "something waiting" where it could say "four". Settings is three groups ordered by how often they are touched. Skipping first, because it is what is actively being tuned. The build hash drops to the bottom under About, rather than sitting between the automation rules and the subscriptions at equal weight. Two things left Settings. Playback speed was already a tap on the episode screen, so the permanent row of chips was a second way to do one thing. The sleep timer moved to the episode overflow, where every other secondary playback action went in step 3 -- it is not a setting, it is something you do once, while listening, usually in the dark. The entry shows the time remaining, which is the only question anyone has about a timer they set in the dark. The reachability test earned its keep here: dropping the speed section orphaned PlayerViewModel.setSpeed, which it reported as a feature no screen could reach. It was genuinely superseded -- setSpeedFor persists the choice per show and applies it -- so it is deleted rather than given a new home. Verified on the emulator, which is the only way Compose gets verified here: the library renders dots, counts and a dimmed errored feed; the three settings groups read in order; and the sleep timer arms from the overflow and then reports "Sleep timer - 29:51". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt56
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt23
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt3
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt25
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt52
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt36
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt108
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt147
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt2
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionListingTest.kt37
-rw-r--r--app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt38
13 files changed, 375 insertions, 163 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
index 8f2f7b6..09a443e 100644
--- a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
@@ -205,4 +205,60 @@ class LibraryOrderTest {
assertEquals(true, waiting("undated"))
}
+
+ /**
+ * How many episodes a show has waiting, not merely whether it has any.
+ *
+ * The library row shows the number. The query already counted it to do the ordering; it was
+ * being collapsed to a boolean on the way out, so the screen had to say "something" where it
+ * could say "four".
+ */
+ @Test
+ fun reportsHowManyEpisodesAreWaiting() = runBlocking {
+ show("many")
+ show("one")
+ show("none")
+ episode("a", "many", day)
+ episode("b", "many", 2 * day)
+ episode("c", "many", 3 * day)
+ episode("d", "one", day)
+
+ val counts = listing().associate { it.subscription.id to it.waiting }
+
+ assertEquals(3, counts["many"])
+ assertEquals(1, counts["one"])
+ assertEquals(0, counts["none"])
+ }
+
+ @Test
+ fun aQueuedEpisodeIsNoLongerWaiting() = runBlocking {
+ show("s")
+ episode("a", "s", day)
+ episode("b", "s", 2 * day)
+ queue.upsertAll(listOf(QueueEntity("a", 0)))
+
+ assertEquals(1, listing().single().waiting)
+ }
+
+ @Test
+ fun aPlayedEpisodeIsNoLongerWaiting() = runBlocking {
+ show("s")
+ episode("a", "s", day)
+ episode("b", "s", 2 * day)
+ episodes.markPlayed("a", 1)
+
+ assertEquals(1, listing().single().waiting)
+ }
+
+ @Test
+ fun hasWaitingStillAnswersTheQuestionItAlwaysDid() = runBlocking {
+ show("quiet")
+ show("busy")
+ episode("a", "busy", day)
+
+ val byId = listing().associateBy { it.subscription.id }
+
+ assertEquals(false, byId["quiet"]!!.hasWaiting)
+ assertEquals(true, byId["busy"]!!.hasWaiting)
+ }
}
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index 08f8c45..292424a 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -18,8 +18,17 @@ import kotlinx.coroutines.flow.Flow
*/
data class SubscriptionListing(
@Embedded val subscription: SubscriptionEntity,
- val hasWaiting: Boolean,
-)
+ /**
+ * How many episodes are unplayed and unqueued -- what the library row shows.
+ *
+ * A count rather than a flag because the query had already counted it to do the ordering,
+ * and the screen was reduced to saying "something waiting" where it could say "four".
+ */
+ val waiting: Int,
+) {
+ /** Whether anything is waiting at all; what dims a show you are caught up on. */
+ val hasWaiting: Boolean get() = waiting > 0
+}
@Dao
interface SubscriptionDao {
@@ -37,13 +46,13 @@ interface SubscriptionDao {
* [bedtime] picks which of the two lists this is. They are disjoint, not a filter over one
* list: a bedtime show is absent from the ordinary library entirely, which is the point.
*
- * [SubscriptionListing.hasWaiting] comes from a COUNT rather than from `latest`, even
- * though the sort key is right there. An episode with no publish date ranks at 0, which is
- * fine for ordering and wrong for "has something waiting" -- reading the flag off the sort
- * key would grey out a show that has an undated episode sitting unheard.
+ * [SubscriptionListing.waiting] comes from a COUNT rather than from `latest`, even though
+ * the sort key is right there. An episode with no publish date ranks at 0, which is fine
+ * for ordering and wrong for "has something waiting" -- reading the flag off the sort key
+ * would grey out a show that has an undated episode sitting unheard.
*/
@Query(
- """SELECT s.*, COALESCE(e.waiting, 0) > 0 AS hasWaiting FROM subscriptions s
+ """SELECT s.*, COALESCE(e.waiting, 0) AS waiting FROM subscriptions s
LEFT JOIN (SELECT subscriptionId, COUNT(*) AS waiting,
MAX(COALESCE(publishedAtMs, 0)) AS latest
FROM episodes
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
index 06b9ef3..b4e9d8a 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
@@ -22,4 +22,7 @@ data class EpisodeDetailActions(
/** Skips back or forward by a step, from wherever this episode currently is. */
val skip: (deltaMs: Long) -> Unit,
val setSpeed: (Float) -> Unit,
+ /** Stops playback after this many minutes, fading over the last twenty seconds. */
+ val armSleepTimer: (minutes: Long) -> Unit = {},
+ val cancelSleepTimer: () -> Unit = {},
)
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
index 01f2f82..3b3b949 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
@@ -11,9 +11,13 @@ data class OverflowContent(
val loadChapters: suspend () -> List<Chapter>,
val watchLink: String?,
val positionMs: Long,
+ /** Milliseconds left on the sleep timer, or null when none is running. */
+ val sleepRemainingMs: Long? = null,
)
-enum class EpisodeMenuAction { QUEUE, DOWNLOAD, PUBLISHER_COPY, TOGGLE_PLAYED, WATCH, MARK_OLDER_PLAYED, CHAPTERS }
+enum class EpisodeMenuAction {
+ QUEUE, DOWNLOAD, PUBLISHER_COPY, TOGGLE_PLAYED, WATCH, MARK_OLDER_PLAYED, CHAPTERS, SLEEP
+}
data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String)
@@ -29,7 +33,16 @@ data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String)
*/
object EpisodeMenu {
- fun items(episode: EpisodeEntity, watchLink: String?): List<EpisodeMenuItem> = buildList {
+ /**
+ * [sleepRemainingMs] is null when no sleep timer is running. It is shown on the entry so the
+ * remaining time can be read without opening anything -- the one question anyone has about a
+ * timer they set in the dark.
+ */
+ fun items(
+ episode: EpisodeEntity,
+ watchLink: String?,
+ sleepRemainingMs: Long? = null,
+ ): List<EpisodeMenuItem> = buildList {
add(EpisodeMenuItem(EpisodeMenuAction.QUEUE, "Queue"))
add(EpisodeMenuItem(EpisodeMenuAction.DOWNLOAD, "Download"))
WaitingForServerText.publisherOffer(episode)?.let {
@@ -44,5 +57,13 @@ object EpisodeMenu {
if (watchLink != null) add(EpisodeMenuItem(EpisodeMenuAction.WATCH, "Watch"))
add(EpisodeMenuItem(EpisodeMenuAction.MARK_OLDER_PLAYED, "Mark this and older as played"))
if (episode.chaptersUrl != null) add(EpisodeMenuItem(EpisodeMenuAction.CHAPTERS, "Chapters"))
+ // Not a setting. It is something you do once, while listening, usually in the dark, so it
+ // belongs with the other secondary playback actions rather than three screens away.
+ add(
+ EpisodeMenuItem(
+ EpisodeMenuAction.SLEEP,
+ if (sleepRemainingMs == null) "Sleep timer" else "Sleep timer \u00b7 ${formatClock(sleepRemainingMs)}",
+ ),
+ )
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
index e872aed..6380b51 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
@@ -52,13 +52,16 @@ fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) {
Icon(Icons.Default.MoreVert, contentDescription = "More actions")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
- EpisodeMenu.items(content.episode, content.watchLink).forEach { item ->
+ EpisodeMenu.items(content.episode, content.watchLink, content.sleepRemainingMs).forEach { item ->
DropdownMenuItem(
text = { Text(item.label) },
onClick = {
open = false
when (item.action) {
- EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY -> dialog = item.action
+ EpisodeMenuAction.CHAPTERS,
+ EpisodeMenuAction.PUBLISHER_COPY,
+ EpisodeMenuAction.SLEEP,
+ -> dialog = item.action
else -> perform(item.action, actions, content.watchLink, context)
}
},
@@ -70,6 +73,7 @@ fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) {
EpisodeMenuAction.CHAPTERS -> ChaptersDialog(content, actions.seekTo) { dialog = null }
EpisodeMenuAction.PUBLISHER_COPY ->
PublisherCopyDialog(content.episode, actions.downloadFromPublisher) { dialog = null }
+ EpisodeMenuAction.SLEEP -> SleepTimerDialog(content.sleepRemainingMs, actions) { dialog = null }
else -> Unit
}
}
@@ -85,10 +89,52 @@ private fun perform(action: EpisodeMenuAction, actions: EpisodeDetailActions, wa
EpisodeMenuAction.WATCH -> watchLink?.let {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, it.toUri())) }
}
- EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY -> Unit
+ EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY, EpisodeMenuAction.SLEEP -> Unit
}
}
+/**
+ * Set a sleep timer, or cancel the one running.
+ *
+ * Durations rather than a picker: nobody sleeps in 37 minutes. The remaining time is shown
+ * because the whole point of opening this a second time is to find out how long is left.
+ */
+@Composable
+private fun SleepTimerDialog(remainingMs: Long?, actions: EpisodeDetailActions, dismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = dismiss,
+ title = { Text("Sleep timer") },
+ text = {
+ Column {
+ Text(
+ if (remainingMs == null) {
+ "Volume fades over the last 20 seconds rather than cutting."
+ } else {
+ "Stopping in ${formatClock(remainingMs)}. Volume fades over the last 20 seconds."
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Row(Modifier.padding(top = 12.dp)) {
+ SLEEP_MINUTES.forEach { minutes ->
+ TextButton(onClick = { dismiss(); actions.armSleepTimer(minutes) }) {
+ Text("${minutes}m")
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ if (remainingMs != null) {
+ TextButton(onClick = { dismiss(); actions.cancelSleepTimer() }) { Text("Cancel timer") }
+ }
+ },
+ dismissButton = { TextButton(onClick = dismiss) { Text("Close") } },
+ )
+}
+
+/** Offered sleep durations. */
+private val SLEEP_MINUTES = listOf(15L, 30L, 45L, 60L)
+
/** The way out of a wait and what it costs, together; wording is [WaitingForServerText]'s. */
@Composable
private fun PublisherCopyDialog(episode: EpisodeEntity, confirm: () -> Unit, dismiss: () -> Unit) {
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
index e9c0db8..7ad3740 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -75,6 +75,7 @@ fun EpisodeScreen(
val rules by vm.rules.collectAsState()
val judging = rememberBreakJudging(vm, episodeId, ranges)
val nowPlaying by player.nowPlaying.collectAsState()
+ val sleepRemaining by player.sleepRemainingMs.collectAsState()
val showTitle = vm.subscriptions.collectAsState().value
.firstOrNull { it.id == episode?.subscriptionId }?.title
@@ -88,13 +89,7 @@ fun EpisodeScreen(
topBar = {
EpisodeTopBar(EpisodeTitles.topBar(showTitle), nav.back) {
current?.let { shown ->
- val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs
- val chapters: suspend () -> List<Chapter> =
- { EpisodeOpening.chapters(shown, ScreenSource(vm, player)) }
- EpisodeOverflow(
- OverflowContent(shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs),
- episodeActions(vm, player, shown, nowPlaying, hooks),
- )
+ Overflow(ScreenSource(vm, player), shown, nowPlaying, sleepRemaining, hooks)
}
}
},
@@ -121,7 +116,7 @@ fun EpisodeScreen(
}
}
-private class ScreenSource(private val vm: ListenViewModel, private val player: PlayerViewModel) :
+private class ScreenSource(val vm: ListenViewModel, val player: PlayerViewModel) :
EpisodeScreenSource {
override suspend fun episode(id: String) = vm.episode(id)
override suspend fun midrollRanges(id: String) = vm.midrollRanges(id)
@@ -182,6 +177,8 @@ private fun episodeActions(
else -> player.skipForward()
}
},
+ armSleepTimer = player::armSleepTimer,
+ cancelSleepTimer = player::cancelSleepTimer,
setSpeed = { chosen ->
hooks.onSpeed(chosen)
player.setSpeedFor(current, chosen)
@@ -329,3 +326,26 @@ private fun RuleAttribution(episode: EpisodeEntity, ruleName: String?) {
val label = if (ruleName != null) "Handled by \"$ruleName\" — $reason" else "Handled by a rule — $reason"
Text(label, style = MaterialTheme.typography.bodySmall)
}
+
+/**
+ * The top bar's overflow, built from whatever is currently on screen.
+ *
+ * Its own composable only to keep [EpisodeScreen] readable -- gathering the position, the
+ * chapter loader and the sleep state inline pushed that function past the length the linter
+ * allows, which was fair warning rather than an obstacle.
+ */
+@Composable
+private fun Overflow(
+ source: ScreenSource,
+ shown: EpisodeEntity,
+ nowPlaying: NowPlaying,
+ sleepRemainingMs: Long?,
+ hooks: EpisodeHooks,
+) {
+ val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs
+ val chapters: suspend () -> List<Chapter> = { EpisodeOpening.chapters(shown, source) }
+ EpisodeOverflow(
+ OverflowContent(shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs, sleepRemainingMs),
+ episodeActions(source.vm, source.player, shown, nowPlaying, hooks),
+ )
+}
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
index 1deb2e9..178a19e 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@@ -165,30 +167,64 @@ private fun LibraryList(
) {
LazyColumn(Modifier.fillMaxSize()) {
if (queue.isNotEmpty()) {
- item { UpNextCard(queue, onOpenQueue) }
+ item { SectionHeading("Up next \u00b7 ${queue.size}") }
+ item { UpNextRow(queue.first(), onOpenQueue) }
}
if (subscriptions.isEmpty()) {
item { if (bedtime) EmptyBedtime() else EmptyLibrary() }
+ } else {
+ item { SectionHeading("Shows") }
}
items(subscriptions, key = { it.subscription.id }) { listing ->
SubscriptionRow(listing) { onOpenSubscription(listing.subscription.id) }
- HorizontalDivider()
}
}
}
+/**
+ * A quiet label, not a card.
+ *
+ * The library used to open with a card around Up Next, which made one list into two things with
+ * a border between them. A heading says the same without the furniture, and the rows beneath it
+ * line up with the rows beneath "Shows".
+ */
@Composable
-private fun UpNextCard(queue: List<EpisodeEntity>, onOpenQueue: () -> Unit) {
- Card(Modifier.fillMaxWidth().padding(12.dp).clickable(onClick = onOpenQueue)) {
- Column(Modifier.padding(16.dp)) {
- Text("Up Next", style = MaterialTheme.typography.titleMedium)
- Text(
- "${queue.size} episode(s) · ${queue.first().title}",
- style = MaterialTheme.typography.bodySmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
+private fun SectionHeading(text: String) {
+ Text(
+ text,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
+ )
+}
+
+/**
+ * The next thing to listen to, as a row like any other.
+ *
+ * Only the first: the count is in the heading, and the queue has its own screen for the rest.
+ * Peter asked for that screen to stay -- "Leave it" -- so this is a way in, not a replacement.
+ */
+@Composable
+private fun UpNextRow(episode: EpisodeEntity, onOpenQueue: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().clickable(onClick = onOpenQueue).padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = episode.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+ Text(
+ episode.title,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f).padding(start = 12.dp),
+ )
}
}
@@ -249,42 +285,50 @@ private fun SubscriptionRow(listing: SubscriptionListing, onClick: () -> Unit) {
val subscription = listing.subscription
val alpha = if (listing.hasWaiting) 1f else CAUGHT_UP_ALPHA
- Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) {
- AsyncImage(
- model = subscription.imageUrl,
- contentDescription = null,
- alpha = alpha,
- modifier = Modifier
- .size(48.dp)
- .clip(RoundedCornerShape(8.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant),
+ Row(
+ Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // A dot rather than artwork. Forty-three shows is a list to scan, not a shelf to browse,
+ // and the one thing worth seeing per row is whether there is anything in it.
+ Box(
+ Modifier
+ .size(WAITING_DOT)
+ .clip(CircleShape)
+ .background(
+ if (listing.hasWaiting) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(
subscription.title,
- style = MaterialTheme.typography.titleMedium,
+ style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
- maxLines = 2,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
- subscription.author?.let {
- Text(
- it,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
- maxLines = 1,
- )
- }
// A feed that has quietly stopped working is otherwise indistinguishable from one
// with nothing new, which is the whole reason the error is stored rather than logged.
+ // Kept when everything else about the row got shorter: it is the only line here that
+ // reports something being wrong.
subscription.lastError?.let {
Text(
"Last refresh failed: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
)
}
}
+ Text(
+ if (listing.hasWaiting) listing.waiting.toString() else "\u2014",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
+ )
}
}
+/** How big the "something waiting" dot is. Small enough to read as punctuation, not a control. */
+private val WAITING_DOT = 8.dp
diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
index d89cde3..b2879cb 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -107,7 +107,7 @@ private fun ListenApp(
)
}
episodeRoute(nav, viewModel, player)
- settingsRoute(nav, opmlViewModel, player)
+ settingsRoute(nav, opmlViewModel)
rulesRoutes(nav, rulesViewModel)
composable("queue") {
QueueScreen(
@@ -214,15 +214,10 @@ private fun androidx.navigation.NavGraphBuilder.rulesRoutes(
* The settings route, extracted so the app's own navigation graph stays readable as screens are
* added to it.
*/
-private fun NavGraphBuilder.settingsRoute(
- nav: NavHostController,
- opml: OpmlViewModel,
- player: PlayerViewModel,
-) {
+private fun NavGraphBuilder.settingsRoute(nav: NavHostController, opml: OpmlViewModel) {
composable("settings") {
SettingsScreen(
opml = opml,
- player = player,
skip = rememberSkipSettings(),
onOpenRules = { nav.navigate("rules") },
onBack = { nav.popBackStack() },
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
index df9bcc1..a32351b 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -162,8 +162,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
fun skipForward() = connection.seekBy(Transport.FORWARD_MS)
- fun setSpeed(speed: Float) = connection.setSpeed(speed)
-
/** The scrubber, committed once the drag ends rather than on every move. */
fun seekTo(positionMs: Long) = connection.seekTo(positionMs)
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
index cf9ca00..00f973d 100644
--- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -38,25 +38,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-/** Offered speeds. 1.0x stays in the list as the way back to normal. */
-private val SPEEDS = listOf(1.0f, 1.1f, 1.25f, 2.0f)
-
-/** Sleep durations in minutes. */
-private val SLEEP_MINUTES = listOf(15L, 30L, 45L, 60L)
-
private val OPML_MIME_TYPES = arrayOf("text/x-opml", "text/xml", "application/xml", "*/*")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
opml: OpmlViewModel,
- player: PlayerViewModel,
skip: SkipSettings,
onOpenRules: () -> Unit,
onBack: () -> Unit,
) {
- val nowPlaying by player.nowPlaying.collectAsState()
- val sleepRemaining by player.sleepRemainingMs.collectAsState()
val banner by opml.banner.collectAsState()
val snackbar = remember { SnackbarHostState() }
@@ -96,20 +87,19 @@ fun SettingsScreen(
Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
- SpeedSection(current = nowPlaying.speed, onSelect = player::setSpeed)
- HorizontalDivider()
- SleepSection(remainingMs = sleepRemaining, onArm = player::armSleepTimer, onCancel = player::cancelSleepTimer)
- HorizontalDivider()
- RulesSection(onOpenRules)
+ // Three groups, ordered by how often they are touched. Skipping comes first
+ // because it is the thing actively being tuned; the build hash is at the bottom
+ // under About, where a commit hash belongs rather than between the automation rules
+ // and the subscriptions at equal weight.
+ SkippingSection(skip.server, skip.furniture)
HorizontalDivider()
- BuildSection()
- HorizontalDivider()
- SubscriptionsSection(
+ LibrarySection(
+ onOpenRules = onOpenRules,
onImport = { importLauncher.launch(OPML_MIME_TYPES) },
onExport = { exportLauncher.launch("listen-subscriptions.opml") },
)
HorizontalDivider()
- SkipServerSection(skip.server, skip.furniture)
+ AboutSection()
}
}
}
@@ -124,7 +114,7 @@ data class SkipServerSettings(
)
/**
- * Where the skip detection comes from.
+ * Skipping: the thing actually being tuned, so it comes first.
*
* Detection used to run on the phone and could not work there: comparing several episodes'
* audio at once needs more memory than an Android process gets and more time than WorkManager
@@ -136,15 +126,34 @@ data class SkipServerSettings(
* Left blank the app behaves as it did before: downloads from publishers, no detected skips.
*/
@Composable
-private fun SkipServerSection(settings: SkipServerSettings, furniture: FurnitureSkipSettings) {
+private fun SkippingSection(settings: SkipServerSettings, furniture: FurnitureSkipSettings) {
var url by remember(settings.url) { mutableStateOf(settings.url) }
var token by remember(settings.token) { mutableStateOf(settings.token) }
- Section("Skip server") {
+ Section("Skipping") {
+ SwitchRow(
+ label = "Skip the show's own intro and credits",
+ description = "Turn skipping the show's own intro and credits on or off",
+ checked = furniture.skipShowFurniture,
+ onCheckedChange = furniture.onSkipShowFurnitureChange,
+ detail = "Advertising is always skipped. This covers a show's opening theme, " +
+ "its break cue and its closing credits.",
+ )
+ SwitchRow(
+ label = "Race through breaks at 3x instead of skipping",
+ description = "Turn racing through breaks at 3x instead of skipping them on or off",
+ checked = furniture.fastPlayBreaks,
+ onCheckedChange = furniture.onFastPlayBreaksChange,
+ // Says "breaks" rather than "ads" deliberately: this chooses what happens to
+ // whatever is being cut, and the switch above chooses what that is. Labelling it
+ // "ads" undersells it whenever both are on.
+ detail = "Hear a burst of whatever is being cut rather than jumping over it, so " +
+ "you can tell something was there.",
+ )
OutlinedTextField(
value = url,
onValueChange = { url = it },
- label = { Text("Address") },
+ label = { Text("Skip server address") },
placeholder = { Text("https://listen.terst.org") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
@@ -167,105 +176,43 @@ private fun SkipServerSection(settings: SkipServerSettings, furniture: Furniture
style = MaterialTheme.typography.bodySmall,
color = if (settings.rejectedNotice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
)
- SwitchRow(
- label = "Skip the show's own intro and credits",
- description = "Turn skipping the show's own intro and credits on or off",
- checked = furniture.skipShowFurniture,
- onCheckedChange = furniture.onSkipShowFurnitureChange,
- detail = "Advertising is always skipped. This covers a show's opening theme, " +
- "its break cue and its closing credits.",
- )
- SwitchRow(
- label = "Race through breaks at 3x instead of skipping",
- description = "Turn racing through breaks at 3x instead of skipping them on or off",
- checked = furniture.fastPlayBreaks,
- onCheckedChange = furniture.onFastPlayBreaksChange,
- // Says "breaks" rather than "ads" deliberately: this chooses what happens to
- // whatever is being cut, and the switch above chooses what that is. Labelling it
- // "ads" undersells it whenever both are on.
- detail = "Hear a burst of whatever is being cut rather than jumping over it, so " +
- "you can tell something was there.",
- )
- }
-}
-
-@Composable
-private fun SpeedSection(current: Float, onSelect: (Float) -> Unit) {
- Section("Playback speed") {
- // Wraps rather than one fixed row: five speed chips overflowed the width on a phone.
- FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- SPEEDS.forEach { speed ->
- if (current == speed) {
- Button(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") }
- } else {
- OutlinedButton(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") }
- }
- }
- }
- }
-}
-
-@Composable
-private fun SleepSection(remainingMs: Long?, onArm: (Long) -> Unit, onCancel: () -> Unit) {
- Section("Sleep timer") {
- if (remainingMs != null) {
- Text("Stopping in ${formatClock(remainingMs)}")
- Button(onClick = onCancel) { Text("Cancel") }
- } else {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- SLEEP_MINUTES.forEach { minutes ->
- OutlinedButton(onClick = { onArm(minutes) }) { Text("${minutes}m") }
- }
- }
- }
- Text(
- "Volume fades over the last 20 seconds rather than cutting.",
- style = MaterialTheme.typography.bodySmall,
- )
- }
-}
-
-@Composable
-private fun RulesSection(onOpenRules: () -> Unit) {
- Section("Automation") {
- OutlinedButton(onClick = onOpenRules) { Text("Auto-queue rules") }
- Text(
- "Decide what happens to an episode the moment a refresh finds it. Rules run in " +
- "order and the first match wins.",
- style = MaterialTheme.typography.bodySmall,
- )
}
}
+/** What the library does on its own, and how it gets in and out. */
@Composable
-private fun SubscriptionsSection(onImport: () -> Unit, onExport: () -> Unit) {
- Section("Subscriptions") {
+private fun LibrarySection(onOpenRules: () -> Unit, onImport: () -> Unit, onExport: () -> Unit) {
+ Section("Library") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = onOpenRules) { Text("Auto-queue rules") }
// Some exporters use text/xml and some a bare octet-stream, so the filter is broad;
// narrowing it hides the file the user is trying to pick.
- OutlinedButton(onClick = onImport) { Text("Import OPML") }
- OutlinedButton(onClick = onExport) { Text("Export OPML") }
+ OutlinedButton(onClick = onImport) { Text("Import") }
+ OutlinedButton(onClick = onExport) { Text("Export") }
}
Text(
- "OPML carries subscriptions only. Playback positions, history and the queue do not " +
- "transfer from another app.",
+ "Rules decide what happens to an episode the moment a refresh finds it, in order, " +
+ "first match wins. OPML carries subscriptions only -- not positions, history or " +
+ "the queue.",
style = MaterialTheme.typography.bodySmall,
)
}
}
/**
- * Which build this is.
+ * Which build this is, last and smallest.
*
* A cached download and a failed publish look identical from the browser, so the only honest
- * answer to "did the update actually land?" comes from the app itself.
+ * answer to "did the update actually land?" comes from the app itself. That makes it worth
+ * keeping and not worth putting at eye level.
*/
@Composable
-private fun BuildSection() {
- Section("Build") {
+private fun AboutSection() {
+ Section("About") {
Text(
- "${org.terst.listen.BuildConfig.GIT_COMMIT} · built ${org.terst.listen.BuildConfig.BUILD_TIME} UTC",
+ "${org.terst.listen.BuildConfig.GIT_COMMIT} \u00b7 built ${org.terst.listen.BuildConfig.BUILD_TIME} UTC",
style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index 8288e16..a285721 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -16,7 +16,7 @@ class FakeSubscriptionDao : SubscriptionDao {
// pins them against a real database instead. This only honours the bedtime split, which
// callers do branch on.
override fun observeAll(bedtime: Boolean): Flow<List<SubscriptionListing>> =
- flowOf(rows.filter { it.bedtime == bedtime }.map { SubscriptionListing(it, hasWaiting = true) })
+ flowOf(rows.filter { it.bedtime == bedtime }.map { SubscriptionListing(it, waiting = 1) })
override fun observeEvery(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList())
override suspend fun getAll() = rows.toList()
diff --git a/app/src/test/java/org/terst/listen/data/SubscriptionListingTest.kt b/app/src/test/java/org/terst/listen/data/SubscriptionListingTest.kt
new file mode 100644
index 0000000..67d0363
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionListingTest.kt
@@ -0,0 +1,37 @@
+package org.terst.listen.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * What the library row reads off a listing.
+ *
+ * The count comes from SQL and is pinned on a device in `LibraryOrderTest`; this is the part
+ * that is plain Kotlin -- the question "is anything waiting at all", which decides whether a
+ * show is dimmed.
+ */
+class SubscriptionListingTest {
+
+ private fun listing(waiting: Int) = SubscriptionListing(
+ subscription = SubscriptionEntity(id = "s1", feedUrl = "https://x/f.xml", title = "S", addedAtMs = 0),
+ waiting = waiting,
+ )
+
+ @Test
+ fun `a show with episodes waiting is not caught up`() {
+ assertTrue(listing(1).hasWaiting)
+ assertTrue(listing(42).hasWaiting)
+ }
+
+ @Test
+ fun `a show with nothing waiting is caught up`() {
+ assertFalse(listing(0).hasWaiting)
+ }
+
+ @Test
+ fun `the count is what the row shows`() {
+ assertEquals(4, listing(4).waiting)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
index 7ff7711..c5c2c3a 100644
--- a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
+++ b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
@@ -31,7 +31,7 @@ class EpisodeMenuTest {
@Test
fun `an ordinary episode offers the everyday actions and nothing conditional`() {
- assertEquals(listOf(QUEUE, DOWNLOAD, TOGGLE_PLAYED, MARK_OLDER_PLAYED), actions(episode()))
+ assertEquals(listOf(QUEUE, DOWNLOAD, TOGGLE_PLAYED, MARK_OLDER_PLAYED, EpisodeMenuAction.SLEEP), actions(episode()))
}
@Test
@@ -83,4 +83,40 @@ class EpisodeMenuTest {
assertEquals("Mark this and older as played", labels[MARK_OLDER_PLAYED])
assertEquals("Chapters", labels[CHAPTERS])
}
+
+ // --- the sleep timer ---------------------------------------------------------------------
+
+ /**
+ * The sleep timer used to be a row in Settings, which is the wrong place twice over: it is
+ * not a setting, it is a thing you do once, while listening, usually in the dark. The
+ * overflow is where every other secondary playback action went in this same redesign.
+ */
+ @Test
+ fun `the sleep timer is offered while something is playing`() {
+ assertTrue(EpisodeMenu.items(episode(), null, sleepRemainingMs = null).map { it.action }
+ .contains(EpisodeMenuAction.SLEEP))
+ }
+
+ @Test
+ fun `an unarmed timer offers to start one`() {
+ val item = EpisodeMenu.items(episode(), null, sleepRemainingMs = null)
+ .single { it.action == EpisodeMenuAction.SLEEP }
+
+ assertEquals("Sleep timer", item.label)
+ }
+
+ @Test
+ fun `an armed timer says how long is left, so it can be checked without opening anything`() {
+ val item = EpisodeMenu.items(episode(), null, sleepRemainingMs = 12 * 60_000 + 34_000)
+ .single { it.action == EpisodeMenuAction.SLEEP }
+
+ assertEquals("Sleep timer \u00b7 12:34", item.label)
+ }
+
+ @Test
+ fun `the old call shape still means no timer armed`() {
+ val item = EpisodeMenu.items(episode(), null).single { it.action == EpisodeMenuAction.SLEEP }
+
+ assertEquals("Sleep timer", item.label)
+ }
}