diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-11-11 09:38:50 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-11-11 09:38:50 +0000 |
| commit | 4559946420c4e7317ce68acc34f9ece28492163b (patch) | |
| tree | c435bb3f671e7fd09908f5a50d99bc4f9139d64a /app | |
| parent | aba4fb18ef8a542865e10a891081681352b64fcd (diff) | |
The car offers Continue, Short and New
Browsing by show is the wrong shape for driving: it asks you to remember what
you were doing and then find it, which was behind "discovery sucks" and
"scrolling the lists in the car is bad experience". Three lists sit above the
shows now and answer the question actually being asked at a traffic light --
what was I in the middle of, what fits this drive, what is new.
All three come from data the app already keeps. No new column, no migration.
Short cuts off at 20 minutes, which is Peter's call rather than a guess: it
catches the daily news and the shorter curiosity episodes and leaves out
conversation and deep dives. It reads COALESCE(actualDurationMs, durationMs)
-- playbackDurationMs in SQL -- because a feed declaring 22:05 for a file that
runs 28:18 would otherwise put a half-hour episode in a twenty-minute list.
An episode of unknown length is left out; unknown is not short.
Continue is the one list that does not exclude queued episodes, because the
episode you are in the middle of is normally the head of the queue and
excluding it would empty the list in the ordinary case. Short and New exclude
anything started, so finishing something is never buried under a new arrival.
A list holding nothing is left off the root entirely rather than opened and
found empty -- the same dead end the phone's library dims a show for.
The queries are their own narrow CarListDao rather than three more methods on
EpisodeDao, which detekt was right to call already the widest in the app: they
are read-only, they all cross every show rather than belonging to one, and
they exist for a single surface. Not on UiGraph, since no view model wants them.
Verified on the emulator: 9 CarListsDbTest cases against the real tables, and
MediaBrowseTest walks the real session protocol to the Continue node and finds
the part-heard episode in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
9 files changed, 491 insertions, 5 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt b/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt new file mode 100644 index 0000000..76e1177 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt @@ -0,0 +1,184 @@ +package org.terst.listen.data + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.terst.listen.playback.CarLists + +/** + * Android Auto's three lists against the real tables. + * + * The conditions that decide what a driver is offered live in SQL -- they have to, because + * "unplayed across every show" is thousands of rows on a real library -- so this is where they + * are checked. The unit tests cover which lists appear; these cover what is in them. + */ +@RunWith(AndroidJUnit4::class) +class CarListsDbTest { + + private lateinit var db: AppDatabase + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + } + + @After + fun tearDown() = db.close() + + private suspend fun show( + id: String, + bedtime: Boolean = false, + drip: Boolean = false, + ) = db.subscriptions().insert( + SubscriptionEntity( + id = id, feedUrl = "https://x/$id", title = "Show $id", addedAtMs = 0, + bedtime = bedtime, dripEnabled = drip, + ) + ) + + private suspend fun episode( + id: String, + showId: String = "s1", + positionMs: Long = 0, + playedAtMs: Long? = null, + durationMs: Long? = MINUTES_10, + actualDurationMs: Long? = null, + publishedAtMs: Long? = 1_000, + dripReleasedAtMs: Long? = null, + ) = db.episodes().insertAll( + listOf( + EpisodeEntity( + id = id, subscriptionId = showId, guid = id, title = "Episode $id", + mediaUrl = "https://x/$id.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, + positionMs = positionMs, playedAtMs = playedAtMs, durationMs = durationMs, + actualDurationMs = actualDurationMs, publishedAtMs = publishedAtMs, + dripReleasedAtMs = dripReleasedAtMs, + ) + ) + ) + + private suspend fun ids(of: List<EpisodeEntity>) = of.map { it.id } + + @Test + fun continueHoldsWhatWasStartedAndNotFinished() = runBlocking { + show("s1") + episode("started", positionMs = 30_000) + episode("untouched") + episode("finished", positionMs = 30_000, playedAtMs = 5_000) + + assertEquals(listOf("started"), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST))) + } + + /** + * The episode you are in the middle of is normally the head of the queue, so the exclusion + * the other two lists apply would empty this one in the ordinary case. + */ + @Test + fun continueStillOffersAnEpisodeThatIsAlsoQueued() = runBlocking { + show("s1") + episode("started", positionMs = 30_000) + db.queue().upsertAll(listOf(QueueEntity("started", 0))) + + assertEquals(listOf("started"), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST))) + } + + /** + * The case the two duration columns exist for: Radiolab declares 22:05 and serves 28:18, so + * trusting the feed would put a half-hour episode in a list promising twenty minutes. + */ + @Test + fun shortBelievesTheMeasuredLengthOverTheFeedsClaim() = runBlocking { + show("s1") + episode("overrun", durationMs = MINUTES_19, actualDurationMs = MINUTES_28) + episode("honest", durationMs = MINUTES_19, actualDurationMs = MINUTES_19) + + val short = db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST) + + assertEquals(listOf("honest"), ids(short)) + } + + @Test + fun shortLeavesOutAnEpisodeOfUnknownLength() = runBlocking { + show("s1") + episode("nameless", durationMs = null) + episode("known", durationMs = MINUTES_10) + + assertEquals( + listOf("known"), + ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)), + ) + } + + @Test + fun shortAndNewLeaveOutAnythingAlreadyStarted() = runBlocking { + show("s1") + episode("started", positionMs = 30_000) + episode("fresh") + + assertEquals( + listOf("fresh"), + ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)), + ) + assertEquals(listOf("fresh"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + @Test + fun newLeavesOutWhatIsAlreadyQueued() = runBlocking { + show("s1") + episode("queued") + episode("loose") + db.queue().upsertAll(listOf(QueueEntity("queued", 0))) + + assertEquals(listOf("loose"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + /** Driving is the opposite of falling asleep, which is why the car hides bedtime shows. */ + @Test + fun noListOffersABedtimeShow() = runBlocking { + show("bed", bedtime = true) + episode("sleepy", showId = "bed", positionMs = 30_000) + episode("alsoSleepy", showId = "bed") + + assertEquals(emptyList<String>(), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST))) + assertEquals( + emptyList<String>(), + ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)), + ) + assertEquals(emptyList<String>(), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + @Test + fun aDrippingShowOffersOnlyWhatDripHasReleased() = runBlocking { + show("drip", drip = true) + episode("released", showId = "drip", dripReleasedAtMs = 500) + episode("held", showId = "drip", dripReleasedAtMs = null) + + assertEquals(listOf("released"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST))) + } + + @Test + fun newIsNewestFirstAndCapped() = runBlocking { + show("s1") + episode("old", publishedAtMs = 1_000) + episode("newest", publishedAtMs = 3_000) + episode("middle", publishedAtMs = 2_000) + + assertEquals(listOf("newest", "middle", "old"), ids(db.carLists().getNewest(10))) + assertEquals(listOf("newest"), ids(db.carLists().getNewest(1))) + } + + private companion object { + const val MINUTES_10 = 10 * 60 * 1000L + const val MINUTES_19 = 19 * 60 * 1000L + const val MINUTES_28 = 28 * 60 * 1000L + } +} 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 f452c9f..61ffe6a 100644 --- a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt +++ b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt @@ -172,6 +172,47 @@ class MediaBrowseTest { } } + /** + * The root offers "Continue", and it holds the part-heard episode. + * + * Peter's report was that discovery in the car was bad: browsing by show means remembering + * what you were doing and then finding it, which is not a thing to do at a traffic light. + * This walks the real protocol rather than calling the callback, because the root is + * assembled from three queries and a browse id has to survive the round trip -- the same + * seam that made "Up Next" show something other than the queue. + */ + @Test + fun theRootOffersContinueAndItHoldsThePartHeardEpisode() = runBlocking { + val graph = Graph.get(context) + graph.subscriptions.insert(seedShow("browse-cont", "Continue Hour")) + graph.episodes.insertAll( + listOf(seedEpisode("browse-cont-ep", "browse-cont", 1_000).copy(positionMs = 42_000)) + ) + + val browser = connect() + try { + withContext(Dispatchers.Main) { + val continueId = LibraryTree.carListId(CarLists.Node.CONTINUE) + val topIds = browser.getChildren(LibraryTree.ROOT, 0, 20, null) + .await().value!!.map { it.mediaId } + assertTrue( + "root should offer Continue, got $topIds", + topIds.contains(continueId), + ) + + val held = browser.getChildren(continueId, 0, CarLists.MAX_PER_LIST, null) + .await().value!!.map { it.mediaId } + assertTrue( + "Continue should hold the part-heard episode, got $held", + held.contains(LibraryTree.episode(seedEpisode("browse-cont-ep", "browse-cont", 1_000)).mediaId), + ) + } + } finally { + withContext(Dispatchers.Main) { browser.release() } + graph.subscriptions.delete("browse-cont") + } + } + private fun seedShow(id: String, title: String) = SubscriptionEntity( id = id, feedUrl = "https://example.invalid/$id", diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index 0ba2e82..5c6f460 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -41,6 +41,8 @@ class Converters { abstract class AppDatabase : RoomDatabase() { abstract fun subscriptions(): SubscriptionDao abstract fun episodes(): EpisodeDao + abstract fun carLists(): CarListDao + abstract fun playbackState(): PlaybackStateDao abstract fun queue(): QueueDao abstract fun rules(): RuleDao 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 2dda8de..b6c82bd 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -478,6 +478,90 @@ interface MidrollDao { * collaborator, [org.terst.listen.playback.PositionWriter], on a timer, and they are the only * writes in the app whose loss is not recoverable from anywhere else. */ +/** + * The cross-show reads that build Android Auto's three lists, and nothing else. + * + * Its own interface rather than three more methods on [EpisodeDao], which is already the widest + * one in the app: these are read-only, they all cross every show rather than belonging to one, + * and they exist for a single surface. Narrow by the same reasoning as [PlaybackStateDao]. + * + * All three share the conditions the phone's library calls "waiting" -- unplayed, released if + * the show drips, and never a bedtime show, because driving is the opposite of falling asleep. + */ +@Dao +interface CarListDao { + + /** + * Part-heard episodes, for the car's "Continue". + * + * `positionMs > 0` is the whole condition: the app has no last-played timestamp, so what + * makes an episode interesting here is that it was started and not finished, and the order + * has to come from the publish date like everything else. That is a real gap rather than a + * choice -- the ideal order is most-recently-listened -- but it costs a column and a + * migration, and with a handful of part-heard episodes the order barely matters. + * + * Queued episodes are deliberately *not* excluded here, unlike the three lists below and + * the phone's library: the episode you are in the middle of is normally the head of the + * queue, so excluding it would empty this list in the ordinary case. + */ + @Query( + """SELECT ep.* FROM episodes ep + JOIN subscriptions owner ON owner.id = ep.subscriptionId + WHERE ep.playedAtMs IS NULL + AND ep.positionMs > 0 + AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL) + AND owner.bedtime = 0 + ORDER BY ep.publishedAtMs DESC + LIMIT :limit""" + ) + suspend fun getContinuing(limit: Int): List<EpisodeEntity> + + /** + * Unstarted episodes short enough for a short drive, for the car's "Short". + * + * `COALESCE(actualDurationMs, durationMs)` is `playbackDurationMs` in SQL, and the + * preference matters more here than almost anywhere: a feed declaring 22:05 for a file that + * runs 28:18 would put a half-hour episode in a list promising twenty minutes. An episode + * whose length is unknown is left out -- unknown is not short, and guessing wrong is the + * failure this list exists to avoid. + */ + @Query( + """SELECT ep.* FROM episodes ep + JOIN subscriptions owner ON owner.id = ep.subscriptionId + WHERE ep.playedAtMs IS NULL + AND ep.positionMs = 0 + AND ep.id NOT IN (SELECT episodeId FROM queue) + AND COALESCE(ep.actualDurationMs, ep.durationMs) IS NOT NULL + AND COALESCE(ep.actualDurationMs, ep.durationMs) <= :maxDurationMs + AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL) + AND owner.bedtime = 0 + ORDER BY ep.publishedAtMs DESC + LIMIT :limit""" + ) + suspend fun getShort(maxDurationMs: Long, limit: Int): List<EpisodeEntity> + + /** + * The newest unstarted episodes across every show, for the car's "New". + * + * The same set the phone's library counts as waiting -- unplayed, unqueued, released if the + * show drips -- flattened across shows instead of grouped by one. Anything started is left + * to `getContinuing`, which is what keeps the two lists from offering the same episode + * under two names. + */ + @Query( + """SELECT ep.* FROM episodes ep + JOIN subscriptions owner ON owner.id = ep.subscriptionId + WHERE ep.playedAtMs IS NULL + AND ep.positionMs = 0 + AND ep.id NOT IN (SELECT episodeId FROM queue) + AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL) + AND owner.bedtime = 0 + ORDER BY ep.publishedAtMs DESC + LIMIT :limit""" + ) + suspend fun getNewest(limit: Int): List<EpisodeEntity> +} + @Dao interface PlaybackStateDao { @Query("UPDATE episodes SET positionMs = :positionMs WHERE id = :id") diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index e4a35f0..f08c484 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -104,6 +104,11 @@ class AppGraph(private val context: Context) : UiGraph { override val subscriptions: SubscriptionDao get() = db.subscriptions() override val episodes: EpisodeDao get() = db.episodes() + /** + * Deliberately not on [UiGraph]: the three cross-show lists exist for Android Auto, and no + * view model has any use for them. + */ + val carLists: CarListDao get() = db.carLists() override val playbackState: PlaybackStateDao get() = db.playbackState() val queueDao: QueueDao get() = db.queue() override val ruleDao: RuleDao get() = db.rules() diff --git a/app/src/main/java/org/terst/listen/playback/CarLists.kt b/app/src/main/java/org/terst/listen/playback/CarLists.kt new file mode 100644 index 0000000..6e01be4 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/CarLists.kt @@ -0,0 +1,57 @@ +package org.terst.listen.playback + +import java.util.concurrent.TimeUnit + +/** + * The three lists the car offers above the shows. + * + * Browsing by show is wrong for driving: Peter's report was "discovery sucks" and "scrolling + * the lists in the car is bad experience", and the cause is that a show list makes you remember + * what you were doing and then find it. These three answer the question actually being asked at + * a traffic light -- what was I in the middle of, what fits this drive, what is new -- from data + * the app already keeps, with no new column and no migration. + * + * They are deliberately disjoint from each other on one axis: anything started appears in + * [CONTINUE] and nowhere else, so finishing something is never buried under a newer arrival. + */ +object CarLists { + + /** + * The longest episode [SHORT] will offer, 20 minutes. + * + * Peter's call, asked rather than guessed: it catches the daily news and the shorter + * curiosity episodes and leaves out conversation and deep-dive shows. The number is a + * judgement about his driving, not about the data, which is why it is pinned by a test -- + * changing it silently changes what the car offers. + */ + val SHORT_MAX_MS: Long = TimeUnit.MINUTES.toMillis(20) + + /** + * How many episodes any one of these lists will hold. + * + * Smaller than the per-show cap by an order of magnitude, and for the opposite reason: that + * one exists so the browser protocol is not handed 2,753 rows, this one exists because a + * list you have to scroll in a car has already failed. Peter measured the screen at 6-8 + * rows with icons, so twenty is about three flicks -- enough that the list is not obviously + * truncated, few enough that the answer is near the top. + */ + const val MAX_PER_LIST = 20 + + /** One of the three lists, in the order the car shows them. */ + enum class Node(val wire: String, val label: String) { + CONTINUE("continue", "Continue"), + SHORT("short", "Short"), + NEW("new", "New"), + } + + /** + * Which of the lists to put on the root, given how many episodes each holds. + * + * An empty list is left off entirely rather than shown and found empty. Tapping a folder in + * a car costs a glance away from the road, and spending that to be told "nothing here" is + * the same dead end as a show row with nothing waiting -- which the phone's library already + * dims for the same reason. + */ + fun offered(counts: Map<Node, Int>): List<Node> = + Node.entries.filter { (counts[it] ?: 0) > 0 } +} diff --git a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt index 371dd1c..d4acdf2 100644 --- a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt +++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt @@ -22,11 +22,19 @@ object LibraryTree { const val QUEUE = "listen:queue" const val SUBSCRIPTIONS = "listen:subscriptions" private const val SUBSCRIPTION_PREFIX = "listen:subscription:" + private const val LIST_PREFIX = "listen:list:" private const val EPISODE_PREFIX = "listen:episode:" fun subscriptionId(mediaId: String): String? = mediaId.removePrefix(SUBSCRIPTION_PREFIX).takeIf { mediaId.startsWith(SUBSCRIPTION_PREFIX) } + /** The browse id for one of the car's lists. */ + fun carListId(node: CarLists.Node): String = LIST_PREFIX + node.wire + + /** The car list a browse id names, or null if it names something else. */ + fun carList(mediaId: String): CarLists.Node? = CarLists.Node.entries + .firstOrNull { mediaId == carListId(it) } + fun episodeId(mediaId: String): String? = mediaId.removePrefix(EPISODE_PREFIX).takeIf { mediaId.startsWith(EPISODE_PREFIX) } @@ -42,10 +50,18 @@ object LibraryTree { fun episodeRef(episodeId: String): MediaItem = MediaItem.Builder().setMediaId(EPISODE_PREFIX + episodeId).build() - fun topLevel(): List<MediaItem> = listOf( - browsable(QUEUE, "Up Next"), - browsable(SUBSCRIPTIONS, "Podcasts"), - ) + /** + * The root, with whichever of the car lists currently hold something. + * + * Up Next stays first -- in a car it is nearly always what you want, and it is the one list + * the listener curated rather than the app inferred. [CarLists.offered] decides which of + * Continue, Short and New appear at all, so the root never offers a folder that turns out + * to be empty. + */ + fun topLevel(lists: List<CarLists.Node> = emptyList()): List<MediaItem> = + listOf(browsable(QUEUE, "Up Next")) + + lists.map { browsable(carListId(it), it.label) } + + browsable(SUBSCRIPTIONS, "Podcasts") fun subscription(entity: SubscriptionEntity): MediaItem = browsable(SUBSCRIPTION_PREFIX + entity.id, entity.title, entity.author, entity.imageUrl) 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 7a02ba6..e9e8ca7 100644 --- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt +++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch +import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.Graph import org.terst.listen.server.ServerAnswer @@ -112,7 +113,7 @@ class ListenSessionCallback( params: MediaLibraryService.LibraryParams?, ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future { val children = when { - parentId == LibraryTree.ROOT -> LibraryTree.topLevel() + parentId == LibraryTree.ROOT -> LibraryTree.topLevel(offeredCarLists()) parentId == LibraryTree.QUEUE -> graph.queueDao.getEntries().mapNotNull { entry -> @@ -131,6 +132,9 @@ class ListenSessionCallback( graph.subscriptions.observeAll(bedtime = false).first() .map { LibraryTree.subscription(it.subscription) } + LibraryTree.carList(parentId) != null -> + withShows(carListEpisodes(LibraryTree.carList(parentId)!!)) + LibraryTree.subscriptionId(parentId) != null -> { val id = LibraryTree.subscriptionId(parentId)!! val show = graph.subscriptions.getById(id) @@ -313,6 +317,46 @@ class ListenSessionCallback( return (showHits.map(LibraryTree::subscription) + episodeHits).take(MAX_SEARCH_RESULTS) } + /** + * The episodes one of the car's lists holds, newest first and capped. + * + * Each list is its own query rather than one broad fetch filtered here: unplayed across + * every show is thousands of rows on a real library -- one feed in testing produced 2,753 + * on a single refresh -- and all three of these want a different slice of it. + */ + private suspend fun carListEpisodes(node: CarLists.Node) = when (node) { + CarLists.Node.CONTINUE -> graph.carLists.getContinuing(CarLists.MAX_PER_LIST) + CarLists.Node.SHORT -> graph.carLists.getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST) + CarLists.Node.NEW -> graph.carLists.getNewest(CarLists.MAX_PER_LIST) + } + + /** + * Which car lists the root should offer, by asking each one what it holds. + * + * The counts come from the lists themselves rather than from three `COUNT` queries, so the + * root can never advertise a list that turns out to be empty when opened -- the two would + * otherwise be free to drift apart as the conditions change. Each is capped at twenty rows, + * so this is three small queries on a browse of the root. + */ + private suspend fun offeredCarLists(): List<CarLists.Node> = CarLists.offered( + CarLists.Node.entries.associateWith { carListEpisodes(it).size } + ) + + /** + * Episodes as browsable items, each carrying its show's name and artwork. + * + * One pass over the subscriptions rather than a lookup per episode: these lists cross every + * show by definition, so the per-episode query the queue gets away with would be twenty + * round trips here. + */ + private suspend fun withShows(episodes: List<EpisodeEntity>): List<MediaItem> { + val shows = graph.subscriptions.getAll().associateBy { it.id } + return episodes.map { episode -> + val show = shows[episode.subscriptionId] + LibraryTree.episode(episode, show?.title, show?.imageUrl) + } + } + private companion object { const val MAX_EPISODES = 100 const val MAX_EPISODE_HITS_PER_SHOW = 5 diff --git a/app/src/test/java/org/terst/listen/playback/CarListsTest.kt b/app/src/test/java/org/terst/listen/playback/CarListsTest.kt new file mode 100644 index 0000000..f64b334 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/CarListsTest.kt @@ -0,0 +1,53 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.terst.listen.playback.CarLists.Node + +class CarListsTest { + + @Test + fun offersEveryListThatHasSomethingInIt() { + val offered = CarLists.offered(mapOf(Node.CONTINUE to 2, Node.SHORT to 7, Node.NEW to 31)) + + assertEquals(listOf(Node.CONTINUE, Node.SHORT, Node.NEW), offered) + } + + @Test + fun leavesOutAnEmptyList() { + val offered = CarLists.offered(mapOf(Node.CONTINUE to 0, Node.SHORT to 3, Node.NEW to 5)) + + assertEquals(listOf(Node.SHORT, Node.NEW), offered) + } + + @Test + fun treatsAnAbsentCountAsEmpty() { + val offered = CarLists.offered(mapOf(Node.NEW to 4)) + + assertEquals(listOf(Node.NEW), offered) + } + + @Test + fun offersNothingWhenEveryListIsEmpty() { + val offered = CarLists.offered(mapOf(Node.CONTINUE to 0, Node.SHORT to 0, Node.NEW to 0)) + + assertEquals(emptyList<Node>(), offered) + } + + @Test + fun keepsTheOrderTheCarShowsRatherThanTheCallersOrder() { + val offered = CarLists.offered(mapOf(Node.NEW to 1, Node.CONTINUE to 1, Node.SHORT to 1)) + + assertEquals(listOf(Node.CONTINUE, Node.SHORT, Node.NEW), offered) + } + + /** + * Pins the cutoff Peter chose. Not a tautology: it is the one number here that is a + * judgement about his driving rather than about the data, and moving it changes what the + * car offers on a short trip without changing anything that would otherwise fail. + */ + @Test + fun offersShortEpisodesUpToTwentyMinutes() { + assertEquals(20 * 60 * 1000L, CarLists.SHORT_MAX_MS) + } +} |
