summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-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)
+ }
}