diff options
Diffstat (limited to 'app')
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) + } } |
