summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt135
-rw-r--r--app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt59
-rw-r--r--app/src/androidTest/java/org/terst/listen/ui/LibraryRowTest.kt137
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt52
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt5
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt133
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt14
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt156
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt5
10 files changed, 604 insertions, 101 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 09a443e..3df28d0 100644
--- a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
@@ -261,4 +261,139 @@ class LibraryOrderTest {
assertEquals(false, byId["quiet"]!!.hasWaiting)
assertEquals(true, byId["busy"]!!.hasWaiting)
}
+
+ // --- drip shows -------------------------------------------------------------------------
+
+ private suspend fun dripShow(id: String) = subscriptions.insert(
+ SubscriptionEntity(
+ id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0,
+ dripEnabled = true,
+ )
+ )
+
+ private suspend fun release(id: String, atMs: Long) = episodes.markDripReleased(id, atMs)
+
+ /**
+ * Reported live: The New Yorker: Fiction showed 235 waiting and sorted near the top, while
+ * opening it showed nothing to listen to. Drip is holding the back catalogue back, so those
+ * 235 are episodes he cannot reach -- counting them says the show has something for him when
+ * it does not.
+ */
+ @Test
+ fun aDripShowCountsOnlyWhatItHasActuallyReleased() = runBlocking {
+ dripShow("drip")
+ episode("a", "drip", day)
+ episode("b", "drip", 2 * day)
+ episode("c", "drip", 3 * day)
+ release("a", day)
+
+ assertEquals(1, listing().single().waiting)
+ }
+
+ @Test
+ fun aDripShowWithNothingReleasedHasNothingWaiting() = runBlocking {
+ dripShow("drip")
+ episode("a", "drip", day)
+ episode("b", "drip", 2 * day)
+
+ val only = listing().single()
+ assertEquals(0, only.waiting)
+ assertEquals(false, only.hasWaiting)
+ }
+
+ /**
+ * The sort key has to come from the same set as the count. A brand-new episode the feed just
+ * published, still held back by drip, must not lift the show to the top of a list of things
+ * to listen to.
+ */
+ @Test
+ fun anUnreleasedEpisodeDoesNotLiftADripShowUpTheList() = runBlocking {
+ show("ordinary")
+ episode("recent", "ordinary", 5 * day)
+ dripShow("drip")
+ episode("old-released", "drip", day)
+ episode("brand-new-held-back", "drip", 9 * day)
+ release("old-released", day)
+
+ assertEquals(listOf("ordinary", "drip"), listing().map { it.subscription.id })
+ }
+
+ @Test
+ fun aDripShowRanksOnItsNewestReleasedEpisode() = runBlocking {
+ show("ordinary")
+ episode("middling", "ordinary", 3 * day)
+ dripShow("drip")
+ episode("released-newer", "drip", 4 * day)
+ episode("held-back", "drip", 9 * day)
+ release("released-newer", 4 * day)
+
+ assertEquals(listOf("drip", "ordinary"), listing().map { it.subscription.id })
+ }
+
+ @Test
+ fun anOrdinaryShowIsUnaffectedByTheDripRule() = runBlocking {
+ show("ordinary")
+ episode("a", "ordinary", day)
+ episode("b", "ordinary", 2 * day)
+
+ assertEquals(2, listing().single().waiting)
+ }
+
+ // --- the newest thing waiting, per show ---------------------------------------------------
+
+ private suspend fun newest(bedtime: Boolean = false) =
+ subscriptions.observeNewestWaiting(bedtime).first().associate { it.subscriptionId to it.id }
+
+ // The library row names the show, and under it the one episode you would actually play.
+ @Test
+ fun reportsTheNewestWaitingEpisodeForEachShow() = runBlocking {
+ show("a")
+ show("b")
+ episode("a-old", "a", day)
+ episode("a-new", "a", 3 * day)
+ episode("b-only", "b", 2 * day)
+
+ assertEquals(mapOf("a" to "a-new", "b" to "b-only"), newest())
+ }
+
+ @Test
+ fun aShowWithNothingWaitingOffersNothing() = runBlocking {
+ show("caught-up")
+ episode("done", "caught-up", day)
+ episodes.markPlayed("done", 1)
+
+ assertEquals(emptyMap<String, String>(), newest())
+ }
+
+ @Test
+ fun aQueuedEpisodeIsNotWhatTheRowOffers() = runBlocking {
+ show("s")
+ episode("queued", "s", 3 * day)
+ episode("waiting", "s", day)
+ queue.upsertAll(listOf(QueueEntity("queued", 0)))
+
+ assertEquals(mapOf("s" to "waiting"), newest())
+ }
+
+ // Same rule as the count: an episode drip has not released is not something he can play.
+ @Test
+ fun aDripShowOffersItsNewestReleasedEpisode() = runBlocking {
+ dripShow("drip")
+ episode("released", "drip", day)
+ episode("held-back", "drip", 9 * day)
+ release("released", day)
+
+ assertEquals(mapOf("drip" to "released"), newest())
+ }
+
+ @Test
+ fun bedtimeShowsAreTheirOwnList() = runBlocking {
+ show("awake")
+ show("asleep", bedtime = true)
+ episode("day", "awake", day)
+ episode("night", "asleep", day)
+
+ assertEquals(mapOf("awake" to "day"), newest())
+ assertEquals(mapOf("asleep" to "night"), newest(bedtime = true))
+ }
}
diff --git a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
index 640849a..5e3086e 100644
--- a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
@@ -13,7 +13,9 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
+import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
+import org.terst.listen.data.SubscriptionEntity
/**
* Browses the media library the way Android Auto does: over the session protocol from a
@@ -129,4 +131,61 @@ class MediaBrowseTest {
withContext(Dispatchers.Main) { browser.release() }
}
}
+
+ /**
+ * The car lists shows in the same order the phone does: the one with the newest thing still
+ * waiting first.
+ *
+ * Reported from the car -- the list "isn't sorted". It was sorted, alphabetically, which is
+ * a different order from the phone's and so reads as none at all. Alphabetical is also the
+ * wrong order here on its own merits: the car hides played episodes, so a show you are
+ * caught up on browses as an empty list, and burying the ones with something to hear among
+ * them is exactly what a driver cannot do anything about at a traffic light.
+ *
+ * Seeded through the DAOs rather than by subscribing: two real feeds both publish recently,
+ * so their relative order would be whatever the publishers did that week.
+ */
+ @Test
+ fun theCarListsShowsNewestWaitingFirst() = runBlocking {
+ val graph = Graph.get(context)
+ val day = 24 * 60 * 60 * 1000L
+ // Titles deliberately in the opposite order to the recency they will rank by.
+ graph.subscriptions.insert(seedShow("browse-zebra", "Zebra Hour"))
+ graph.subscriptions.insert(seedShow("browse-apple", "Apple Hour"))
+ graph.episodes.insertAll(listOf(seedEpisode("browse-z-ep", "browse-zebra", 3 * day)))
+ graph.episodes.insertAll(listOf(seedEpisode("browse-a-ep", "browse-apple", day)))
+
+ val browser = connect()
+ try {
+ withContext(Dispatchers.Main) {
+ val shows = browser.getChildren(LibraryTree.SUBSCRIPTIONS, 0, 100, null)
+ .await().value!!
+ .mapNotNull { it.mediaMetadata.title?.toString() }
+ .filter { it.endsWith(" Hour") }
+ assertEquals(listOf("Zebra Hour", "Apple Hour"), shows)
+ }
+ } finally {
+ withContext(Dispatchers.Main) { browser.release() }
+ graph.subscriptions.delete("browse-zebra")
+ graph.subscriptions.delete("browse-apple")
+ }
+ }
+
+ private fun seedShow(id: String, title: String) = SubscriptionEntity(
+ id = id,
+ feedUrl = "https://example.invalid/$id",
+ title = title,
+ addedAtMs = 0,
+ )
+
+ private fun seedEpisode(id: String, showId: String, publishedAtMs: Long) = EpisodeEntity(
+ id = id,
+ subscriptionId = showId,
+ guid = id,
+ title = id,
+ mediaUrl = "https://example.invalid/$id.mp3",
+ mediaType = "audio/mpeg",
+ publishedAtMs = publishedAtMs,
+ discoveredAtMs = 0,
+ )
}
diff --git a/app/src/androidTest/java/org/terst/listen/ui/LibraryRowTest.kt b/app/src/androidTest/java/org/terst/listen/ui/LibraryRowTest.kt
new file mode 100644
index 0000000..41dc682
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/ui/LibraryRowTest.kt
@@ -0,0 +1,137 @@
+package org.terst.listen.ui
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionListing
+
+/**
+ * A library row: the show's name, how much is waiting, and the one episode it offers.
+ *
+ * Driven as a composable because the thing that can go wrong is wiring, not appearance -- a row
+ * offering the wrong episode's controls looks exactly like a row offering the right one's, and
+ * the library draws sixty of them.
+ */
+@RunWith(AndroidJUnit4::class)
+class LibraryRowTest {
+
+ @get:Rule
+ val compose = createComposeRule()
+
+ private fun show(id: String, title: String) = SubscriptionEntity(
+ id = id,
+ feedUrl = "https://example.invalid/$id",
+ title = title,
+ addedAtMs = 0,
+ )
+
+ private fun episode(id: String, showId: String, title: String) = EpisodeEntity(
+ id = id,
+ subscriptionId = showId,
+ guid = id,
+ title = title,
+ mediaUrl = "https://example.invalid/$id.mp3",
+ mediaType = "audio/mpeg",
+ publishedAtMs = 0,
+ discoveredAtMs = 0,
+ )
+
+ private fun noActions() = EpisodeActions(
+ play = {}, enqueue = {}, playNext = {}, download = {}, openDetails = {}, togglePlayed = {},
+ )
+
+ @Test
+ fun theShowNameAndWhatIsWaitingAreBothOnTheRow() {
+ compose.setContent {
+ SubscriptionRow(
+ listing = SubscriptionListing(show("s", "Zebra Hour"), waiting = 3),
+ latest = episode("e", "s", "The one about stripes"),
+ actions = noActions(),
+ ) {}
+ }
+
+ compose.onNodeWithText("Zebra Hour").assertIsDisplayed()
+ compose.onNodeWithText("3").assertIsDisplayed()
+ compose.onNodeWithText("The one about stripes").assertIsDisplayed()
+ compose.onNodeWithContentDescription("Download").assertIsDisplayed()
+ }
+
+ /**
+ * A show with nothing waiting offers nothing, rather than offering an episode the count
+ * says is not there.
+ */
+ @Test
+ fun aShowWithNothingWaitingOffersNoEpisode() {
+ compose.setContent {
+ SubscriptionRow(
+ listing = SubscriptionListing(show("s", "Caught Up"), waiting = 0),
+ latest = null,
+ actions = null,
+ ) {}
+ }
+
+ compose.onNodeWithText("Caught Up").assertIsDisplayed()
+ compose.onNodeWithText("—").assertIsDisplayed()
+ // The controls belong to the episode, so with no episode there is nothing to control.
+ compose.onNodeWithContentDescription("Download").assertDoesNotExist()
+ }
+
+ /** The point of the controls: they act on the episode named beside them, not on the show. */
+ @Test
+ fun playActsOnTheEpisodeTheRowNames() {
+ var played = 0
+ compose.setContent {
+ SubscriptionRow(
+ listing = SubscriptionListing(show("s", "Zebra Hour"), waiting = 1),
+ latest = episode("e", "s", "The one about stripes"),
+ actions = noActions().copy(play = { played++ }),
+ ) {}
+ }
+
+ compose.onNodeWithContentDescription("Play The one about stripes").performClick()
+
+ assertEquals(1, played)
+ }
+
+ @Test
+ fun queueActsOnTheEpisodeTheRowNames() {
+ var queued = 0
+ compose.setContent {
+ SubscriptionRow(
+ listing = SubscriptionListing(show("s", "Zebra Hour"), waiting = 1),
+ latest = episode("e", "s", "The one about stripes"),
+ actions = noActions().copy(enqueue = { queued++ }),
+ ) {}
+ }
+
+ compose.onNodeWithContentDescription("Queue The one about stripes").performClick()
+
+ assertEquals(1, queued)
+ }
+
+ /** Tapping the row still opens the show: the episode controls sit inside it, not over it. */
+ @Test
+ fun tappingTheRowOpensTheShow() {
+ var opened = 0
+ compose.setContent {
+ SubscriptionRow(
+ listing = SubscriptionListing(show("s", "Zebra Hour"), waiting = 1),
+ latest = episode("e", "s", "The one about stripes"),
+ actions = noActions(),
+ ) { opened++ }
+ }
+
+ compose.onNodeWithText("Zebra Hour").performClick()
+
+ assertEquals(1, opened)
+ }
+}
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 292424a..74c8ad5 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -43,6 +43,11 @@ interface SubscriptionDao {
* Queued episodes are excluded for the same reason played ones are: they have been dealt
* with. Peter's words -- once they're on the queue they're noise in the list.
*
+ * So are episodes a drip show has not released yet, and for the same reason again: they
+ * cannot be reached. Counting them said The New Yorker: Fiction had 235 waiting and sorted
+ * it near the top, while opening it showed nothing to listen to. The count and the sort key
+ * both come from the released set now, which is the set [getVisible] shows.
+ *
* [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.
*
@@ -53,11 +58,14 @@ interface SubscriptionDao {
*/
@Query(
"""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
- WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue)
- GROUP BY subscriptionId) e
+ LEFT JOIN (SELECT ep.subscriptionId AS subscriptionId, COUNT(*) AS waiting,
+ MAX(COALESCE(ep.publishedAtMs, 0)) AS latest
+ FROM episodes ep
+ JOIN subscriptions owner ON owner.id = ep.subscriptionId
+ WHERE ep.playedAtMs IS NULL
+ AND ep.id NOT IN (SELECT episodeId FROM queue)
+ AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL)
+ GROUP BY ep.subscriptionId) e
ON e.subscriptionId = s.id
WHERE s.bedtime = :bedtime
ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE"""
@@ -65,6 +73,30 @@ interface SubscriptionDao {
fun observeAll(bedtime: Boolean): Flow<List<SubscriptionListing>>
/**
+ * The newest episode still waiting on each show -- what the library row offers under the
+ * show's name.
+ *
+ * "Waiting" is the same set the library counts: unplayed, unqueued, and released if the
+ * show drips. A row that offered an episode the count does not include would be offering
+ * something the show claims not to have.
+ *
+ * One query for every show rather than one per show: the library observes this alongside
+ * the show list, and a per-show query would be sixty round trips on every change to any of
+ * them. SQLite guarantees the bare columns of a `GROUP BY` come from the row that produced
+ * the `MAX`, which is what picks the newest.
+ */
+ @Query(
+ """SELECT ep.*, MAX(COALESCE(ep.publishedAtMs, 0)) AS newest FROM episodes ep
+ JOIN subscriptions owner ON owner.id = ep.subscriptionId
+ WHERE ep.playedAtMs IS NULL
+ AND ep.id NOT IN (SELECT episodeId FROM queue)
+ AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL)
+ AND owner.bedtime = :bedtime
+ GROUP BY ep.subscriptionId"""
+ )
+ fun observeNewestWaiting(bedtime: Boolean): Flow<List<EpisodeEntity>>
+
+ /**
* Every show, bedtime ones included.
*
* Deliberately not the library query: a rule may well target a bedtime show, and a screen
@@ -76,16 +108,6 @@ interface SubscriptionDao {
@Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
suspend fun getAll(): List<SubscriptionEntity>
- /**
- * The shows Android Auto lists, which is everything except bedtime ones.
- *
- * Driving is the opposite of falling asleep, and the car list is scrolled blind at a
- * traffic light -- the same reason played episodes never appear there. Voice search still
- * goes through [getAll]: asking for a show by name is unambiguous about wanting it.
- */
- @Query("SELECT * FROM subscriptions WHERE bedtime = 0 ORDER BY title COLLATE NOCASE")
- suspend fun getBrowsable(): List<SubscriptionEntity>
-
@Query("SELECT * FROM subscriptions WHERE id = :id")
suspend fun getById(id: String): SubscriptionEntity?
diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
index f0c9ae3..723c406 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -17,6 +17,7 @@ import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.guava.future
import org.terst.listen.data.Graph
@@ -118,7 +119,13 @@ class ListenSessionCallback(
}
parentId == LibraryTree.SUBSCRIPTIONS ->
- graph.subscriptions.getBrowsable().map(LibraryTree::subscription)
+ // The library's own query, not a second one ordered differently: the car lists
+ // shows newest-waiting-first for the same reason the phone does, and a separate
+ // ordering here is what made the car list look unsorted. Bedtime shows are left
+ // out by it -- driving is the opposite of falling asleep -- while voice search
+ // still goes through getAll, since asking for a show by name is unambiguous.
+ graph.subscriptions.observeAll(bedtime = false).first()
+ .map { LibraryTree.subscription(it.subscription) }
LibraryTree.subscriptionId(parentId) != null -> {
val id = LibraryTree.subscriptionId(parentId)!!
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
index dd91e18..dac7902 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -89,14 +89,15 @@ internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artwork
}
}
+/** Internal so the library row can offer the same controls without a second set of icons. */
@Composable
-private fun ActionIcon(icon: ImageVector, description: String, onClick: () -> Unit) {
+internal fun ActionIcon(icon: ImageVector, description: String, onClick: () -> Unit) {
IconButton(onClick = onClick) { Icon(icon, contentDescription = description) }
}
/** Download state is the icon itself, so the row doesn't need a separate progress line. */
@Composable
-private fun DownloadIcon(state: DownloadState, onDownload: () -> Unit) {
+internal fun DownloadIcon(state: DownloadState, onDownload: () -> Unit) {
when (state) {
DownloadState.DOWNLOADING ->
IconButton(onClick = {}, enabled = false) {
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 178a19e..a926ce6 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -10,6 +10,7 @@ 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.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -18,6 +19,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.LightMode
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
@@ -52,12 +55,14 @@ import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionListing
+import org.terst.listen.playback.NowPlaying
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) {
val subscriptions by vm.library.collectAsState()
+ val newestWaiting by vm.newestWaiting.collectAsState()
val queue by vm.queue.collectAsState()
val refreshing by vm.refreshing.collectAsState()
val banner by vm.banner.collectAsState()
@@ -94,18 +99,7 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav)
)
},
snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
- bottomBar = {
- NowPlayingBar(
- state = nowPlaying,
- actions = NowPlayingActions(
- togglePlayPause = player::togglePlayPause,
- skipBack = player::skipBack,
- skipForward = player::skipForward,
- openEpisode = { nowPlaying.episodeId?.let(nav.openEpisode) },
- openQueue = nav.openQueue,
- ),
- )
- },
+ bottomBar = { LibraryNowPlayingBar(nowPlaying, player, nav) },
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
if (refreshing) {
@@ -115,11 +109,34 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav)
AddSubscription(onSubmit = { vm.subscribe(it); adding = false })
}
- LibraryList(subscriptions, queue, bedtime, nav.openSubscription, nav.openQueue)
+ LibraryList(
+ LibraryContent(subscriptions, newestWaiting, queue, bedtime),
+ onOpenSubscription = nav.openSubscription,
+ onOpenQueue = nav.openQueue,
+ actionsFor = { episode -> libraryEpisodeActions(vm, player, episode, nav) },
+ )
}
}
}
+@Composable
+private fun LibraryNowPlayingBar(
+ nowPlaying: NowPlaying,
+ player: PlayerViewModel,
+ nav: LibraryNav,
+) {
+ NowPlayingBar(
+ state = nowPlaying,
+ actions = NowPlayingActions(
+ togglePlayPause = player::togglePlayPause,
+ skipBack = player::skipBack,
+ skipForward = player::skipForward,
+ openEpisode = { nowPlaying.episodeId?.let(nav.openEpisode) },
+ openQueue = nav.openQueue,
+ ),
+ )
+}
+
/** The top bar's four callbacks, bundled so the signature stays readable. */
private data class LibraryTopBarActions(
val toggleAdd: () -> Unit,
@@ -157,14 +174,25 @@ private fun LibraryTopBar(
)
}
+/** What the library list draws, bundled so the signature stays readable. */
+internal data class LibraryContent(
+ val subscriptions: List<SubscriptionListing>,
+ /** The newest episode still waiting on each show, keyed by show id. */
+ val newestWaiting: Map<String, EpisodeEntity>,
+ val queue: List<EpisodeEntity>,
+ val bedtime: Boolean,
+)
+
@Composable
-private fun LibraryList(
- subscriptions: List<SubscriptionListing>,
- queue: List<EpisodeEntity>,
- bedtime: Boolean,
+internal fun LibraryList(
+ content: LibraryContent,
onOpenSubscription: (String) -> Unit,
onOpenQueue: () -> Unit,
+ actionsFor: (EpisodeEntity) -> EpisodeActions,
) {
+ val subscriptions = content.subscriptions
+ val queue = content.queue
+ val bedtime = content.bedtime
LazyColumn(Modifier.fillMaxSize()) {
if (queue.isNotEmpty()) {
item { SectionHeading("Up next \u00b7 ${queue.size}") }
@@ -176,7 +204,12 @@ private fun LibraryList(
item { SectionHeading("Shows") }
}
items(subscriptions, key = { it.subscription.id }) { listing ->
- SubscriptionRow(listing) { onOpenSubscription(listing.subscription.id) }
+ val latest = content.newestWaiting[listing.subscription.id]
+ SubscriptionRow(
+ listing = listing,
+ latest = latest,
+ actions = latest?.let(actionsFor),
+ ) { onOpenSubscription(listing.subscription.id) }
}
}
}
@@ -228,9 +261,6 @@ private fun UpNextRow(episode: EpisodeEntity, onOpenQueue: () -> Unit) {
}
}
-/** Dimmed rather than hidden: a show you are caught up on is still one you subscribe to. */
-private const val CAUGHT_UP_ALPHA = 0.38f
-
@Composable
private fun EmptyBedtime() {
Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -271,64 +301,3 @@ private fun AddSubscription(onSubmit: (String) -> Unit) {
Button(onClick = { onSubmit(text) }, enabled = text.isNotBlank()) { Text("Add") }
}
}
-
-/**
- * One show. Dimmed when it has nothing waiting, so a scan down the list separates the shows
- * with something to hear from the ones you are caught up on.
- *
- * The dimming stops short of [SubscriptionEntity.lastError], which keeps its error colour: a
- * feed that is failing to refresh looks exactly like a feed with nothing new, and that is the
- * one case where "nothing waiting" is a lie worth shouting about.
- */
-@Composable
-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(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.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- // 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/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 3115c32..a9f95da 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
@@ -49,6 +50,19 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
/**
+ * The newest episode still waiting on each show, keyed by show.
+ *
+ * What the library row offers under the show's name. Observed as one query across every
+ * show rather than one per row: sixty rows asking separately would be sixty round trips on
+ * every change to any of them.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val newestWaiting: StateFlow<Map<String, EpisodeEntity>> =
+ _bedtime.flatMapLatest { graph.subscriptions.observeNewestWaiting(it) }
+ .map { episodes -> episodes.associateBy { it.subscriptionId } }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyMap())
+
+ /**
* Every show, for the screens that look one up by id.
*
* Separate from [library] on purpose: a show screen opened from bedtime mode must survive
diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt
new file mode 100644
index 0000000..1ffa4a9
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt
@@ -0,0 +1,156 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.PlaylistAdd
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionListing
+
+/** Dimmed rather than hidden: a show you are caught up on is still one you subscribe to. */
+private const val CAUGHT_UP_ALPHA = 0.38f
+
+/**
+ * One show: its name, how much is waiting, and the one episode you would actually play.
+ *
+ * The show name stays the focus -- this is a list of shows, not of episodes -- so the episode
+ * sits under it in a quieter style with its own controls. Those controls are the same ones the
+ * episode list offers, because an episode offering different actions depending on which screen
+ * you found it on would be two features wearing one name.
+ *
+ * Dimmed when nothing is waiting, so a scan down the list separates the shows with something to
+ * hear from the ones you are caught up on. The dimming stops short of
+ * [SubscriptionEntity.lastError], which keeps its error colour: a feed that is failing to
+ * refresh looks exactly like a feed with nothing new, and that is the one case where "nothing
+ * waiting" is a lie worth shouting about.
+ */
+@Composable
+internal fun SubscriptionRow(
+ listing: SubscriptionListing,
+ latest: EpisodeEntity?,
+ actions: EpisodeActions?,
+ onClick: () -> Unit,
+) {
+ val subscription = listing.subscription
+ val alpha = if (listing.hasWaiting) 1f else CAUGHT_UP_ALPHA
+
+ Column(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(start = 16.dp, end = 4.dp)) {
+ ShowHeadline(listing, alpha)
+ if (latest != null && actions != null) LatestEpisodeLine(latest, actions)
+
+ // 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.
+ subscription.lastError?.let {
+ Text(
+ "Last refresh failed: $it",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(start = WAITING_DOT + 12.dp, bottom = 4.dp),
+ )
+ }
+ Spacer(Modifier.size(6.dp))
+ }
+}
+
+/** The show's own line: a dot for whether anything is waiting, its name, and how much. */
+@Composable
+private fun ShowHeadline(listing: SubscriptionListing, alpha: Float) {
+ Row(Modifier.fillMaxWidth().padding(top = 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
+ )
+ )
+ Text(
+ listing.subscription.title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f).padding(start = 12.dp),
+ )
+ Text(
+ if (listing.hasWaiting) listing.waiting.toString() else "\u2014",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
+ modifier = Modifier.padding(end = 12.dp),
+ )
+ }
+}
+
+/**
+ * The newest waiting episode, indented under the show's name and offered directly.
+ *
+ * Indented to the show title's text, not the dot: the dot belongs to the show, and lining the
+ * episode up with it would read as a second show rather than as this one's contents.
+ */
+@Composable
+private fun LatestEpisodeLine(latest: EpisodeEntity, actions: EpisodeActions) {
+ Row(
+ Modifier.fillMaxWidth().padding(start = WAITING_DOT + 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ latest.title,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ ActionIcon(Icons.Default.PlayArrow, "Play ${latest.title}", actions.play)
+ ActionIcon(Icons.Default.PlaylistAdd, "Queue ${latest.title}", actions.enqueue)
+ DownloadIcon(latest.downloadState, actions.download)
+ }
+}
+
+/** How big the "something waiting" dot is. Small enough to read as punctuation, not a control. */
+private val WAITING_DOT = 8.dp
+
+/**
+ * The controls the library row offers on a show's newest waiting episode.
+ *
+ * Deliberately the same actions the episode list builds, so an episode does not behave
+ * differently depending on which screen it was found on.
+ */
+internal fun libraryEpisodeActions(
+ vm: ListenViewModel,
+ player: PlayerViewModel,
+ episode: EpisodeEntity,
+ nav: LibraryNav,
+) = EpisodeActions(
+ play = { player.play(episode) },
+ enqueue = { vm.enqueue(episode.id) },
+ playNext = { vm.playNext(episode.id) },
+ download = { vm.download(episode) },
+ openDetails = { nav.openEpisode(episode.id) },
+ togglePlayed = {
+ if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
+ },
+)
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 a285721..54ef70c 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -19,8 +19,11 @@ class FakeSubscriptionDao : SubscriptionDao {
flowOf(rows.filter { it.bedtime == bedtime }.map { SubscriptionListing(it, waiting = 1) })
override fun observeEvery(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList())
+
+ // Which episode is newest and still waiting is SQL too, and no JVM caller branches on it:
+ // the library screen is the only consumer. LibraryOrderTest pins it on a real database.
+ override fun observeNewestWaiting(bedtime: Boolean): Flow<List<EpisodeEntity>> = flowOf(emptyList())
override suspend fun getAll() = rows.toList()
- override suspend fun getBrowsable() = rows.filterNot { it.bedtime }
override suspend fun getById(id: String) = rows.firstOrNull { it.id == id }
override suspend fun getByFeedUrl(feedUrl: String) = rows.firstOrNull { it.feedUrl == feedUrl }