diff options
11 files changed, 453 insertions, 6 deletions
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 958e2a5..ea8899b 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -11,16 +11,17 @@ import kotlinx.coroutines.flow.Flow @Dao interface SubscriptionDao { /** - * Most recently published first. + * Newest *unplayed* episode first. * - * Alphabetical put the show you are least likely to want next at the top as often as not; - * what a listener is looking for is whatever has something new. Shows with no episodes yet - * sort last rather than first, which is what COALESCE to 0 does here. + * Not newest episode: a show you are caught up on would sit at the top having nothing for + * you, which is the opposite of useful. Sorting on what is still unheard puts whatever + * actually wants listening to at the top, and drops a finished show down the list until it + * publishes again. Shows with nothing unplayed sort last, which is the COALESCE to 0. */ @Query( """SELECT s.* FROM subscriptions s LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest - FROM episodes GROUP BY subscriptionId) e + FROM episodes WHERE playedAtMs IS NULL GROUP BY subscriptionId) e ON e.subscriptionId = s.id ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE""" ) @@ -91,6 +92,25 @@ interface EpisodeDao { ) suspend fun updateDownload(id: String, state: DownloadState, localPath: String?, downloadedAtMs: Long?) + /** + * Marks an episode and everything older in the same podcast as played. + * + * The case this exists for is an OPML import: every episode of every show arrives unplayed, + * which is a back catalogue of things already heard and far too many to tap individually. + * + * `playedAtMs IS NULL` keeps an already-played episode's original date, which recorded when + * it was actually finished. Positions are cleared because retention reads a non-zero + * position as "resumed, still in use" and would never reclaim the files. + */ + @Query( + """UPDATE episodes SET playedAtMs = :atMs, positionMs = 0 + WHERE subscriptionId = :subscriptionId + AND publishedAtMs IS NOT NULL + AND publishedAtMs <= :publishedAtMs + AND playedAtMs IS NULL""" + ) + suspend fun markPlayedUpTo(subscriptionId: String, publishedAtMs: Long, atMs: Long) + /** Candidates for retention: played, downloaded, and past their grace period. */ @Query( """SELECT * FROM episodes diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt index e19834e..c3d1d72 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt @@ -50,5 +50,6 @@ data class EpisodeActions( val playNext: () -> Unit, val download: () -> Unit, val togglePlayed: () -> Unit, + val openDetails: () -> Unit, ) diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt new file mode 100644 index 0000000..5ef0ff1 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt @@ -0,0 +1,46 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 coil3.compose.AsyncImage +import org.terst.listen.data.EpisodeEntity + +/** The detail screen's actions, bundled so the signature stays readable. */ +data class EpisodeDetailActions( + val play: () -> Unit, + val enqueue: () -> Unit, + val download: () -> Unit, + val togglePlayed: () -> Unit, + val markOlderPlayed: () -> Unit, +) 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 20e46cc..99fe053 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt @@ -1,6 +1,7 @@ package org.terst.listen.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -51,7 +52,14 @@ internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artwork .background(MaterialTheme.colorScheme.surfaceVariant), ) Column(Modifier.weight(1f).padding(start = 12.dp)) { - Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2) + // Tapping the title opens the episode: the list can only show a couple of lines and + // the description has nowhere else to go. + Text( + episode.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + modifier = Modifier.clickable(onClick = actions.openDetails), + ) Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt new file mode 100644 index 0000000..72434a0 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -0,0 +1,158 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 coil3.compose.AsyncImage +import org.terst.listen.data.EpisodeEntity + +/** + * One episode in full. + * + * The list can only show a couple of lines, so the description — often the only place the show + * says what an episode is actually about — had nowhere to go. This is also where bulk actions + * live: the row has five controls already and a sixth would not fit. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EpisodeScreen( + vm: ListenViewModel, + player: PlayerViewModel, + episodeId: String, + nav: SubscriptionNav, +) { + var episode by remember { mutableStateOf<EpisodeEntity?>(null) } + LaunchedEffect(episodeId) { episode = vm.episode(episodeId) } + + val current = episode + Scaffold( + topBar = { + TopAppBar( + title = { Text(current?.title ?: "Episode", maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = nav.back) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) }, + ) { padding -> + if (current == null) { + Text("Episode not found", Modifier.padding(padding).padding(24.dp)) + return@Scaffold + } + EpisodeDetail( + episode = current, + modifier = Modifier.padding(padding), + actions = EpisodeDetailActions( + play = { player.play(current); nav.openPlayer() }, + enqueue = { vm.enqueue(current.id) }, + download = { vm.download(current) }, + togglePlayed = { + if (current.playedAtMs == null) vm.markPlayed(current.id) else vm.markUnplayed(current.id) + episode = current.copy(playedAtMs = if (current.playedAtMs == null) 1L else null) + }, + markOlderPlayed = { vm.markPlayedUpTo(current) }, + ), + ) + } +} + +@Composable +private fun EpisodeDetail( + episode: EpisodeEntity, + modifier: Modifier, + actions: EpisodeDetailActions, +) { + Column( + modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AsyncImage( + model = episode.imageUrl, + contentDescription = null, + modifier = Modifier + .size(96.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + Column(Modifier.weight(1f)) { + Text(episode.title, style = MaterialTheme.typography.titleMedium) + Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) + } + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = actions.play) { Text(if (episode.positionMs > 0) "Resume" else "Play") } + OutlinedButton(onClick = actions.enqueue) { Text("Queue") } + OutlinedButton(onClick = actions.download) { Text("Download") } + OutlinedButton(onClick = actions.togglePlayed) { + Text(if (episode.playedAtMs == null) "Played" else "Unplayed") + } + } + + // The import case: a freshly imported back catalogue is entirely unplayed, and this is + // the only practical way to say "I've heard everything up to here". + OutlinedButton(onClick = actions.markOlderPlayed, modifier = Modifier.fillMaxWidth()) { + Text("Mark this and older as played") + } + + episode.description?.let { + Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium) + } + } +} + +/** + * Show notes are HTML in practice, whatever the spec says. + * + * Rendered raw they appear as a wall of tags. This is not a parser — it strips tags, decodes the + * handful of entities that actually show up, and collapses the whitespace that leaves behind. + */ +internal fun stripHtml(raw: String): String = raw + .replace(Regex("""<br\s*/?>|</p>|</div>""", RegexOption.IGNORE_CASE), "\n") + .replace(Regex("""<[^>]+>"""), "") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(Regex("""\n{3,}"""), "\n\n") + .lines().joinToString("\n") { it.trim() } + .trim() 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 1ff29f6..f439a2f 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -100,6 +100,8 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { _banner.value = null } + suspend fun episode(id: String): EpisodeEntity? = graph.episodes.getById(id) + fun episodesOf(subscriptionId: String) = graph.episodes.observeForSubscription(subscriptionId) /** @@ -211,6 +213,24 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { graph.episodes.markPlayed(episodeId, System.currentTimeMillis()) } } + /** + * Marks an episode and everything older in the same show as played. + * + * The OPML-import case: a freshly imported back catalogue is entirely unplayed, and the + * only practical way to say "I've heard everything up to here" is in one action. + */ + fun markPlayedUpTo(episode: EpisodeEntity) { + val publishedAtMs = episode.publishedAtMs + if (publishedAtMs == null) { + _banner.value = Banner("That episode has no date, so there's no 'older' to mark", isError = true) + return + } + viewModelScope.launch { + graph.episodes.markPlayedUpTo(episode.subscriptionId, publishedAtMs, System.currentTimeMillis()) + _banner.value = Banner("Marked this and older as played") + } + } + fun markUnplayed(episodeId: String) { viewModelScope.launch { graph.episodes.markUnplayed(episodeId) } } 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 61416a3..e483acc 100644 --- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -58,6 +58,7 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) { composable("player") { PlayerScreen(player = player, onBack = { nav.popBackStack() }) } + episodeRoute(nav, viewModel, player) composable("settings") { SettingsScreen(vm = viewModel, player = player, onBack = { nav.popBackStack() }) @@ -81,6 +82,7 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) { player = player, subscriptionId = id, nav = SubscriptionNav( + openEpisode = { nav.navigate("episode/$it") }, openPlayer = { nav.navigate("player") }, openQueue = { nav.navigate("queue") }, back = { nav.popBackStack() }, @@ -90,3 +92,34 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) { } } } + +/** + * The episode detail route. + * + * Extracted because the nav graph grew past the length limit as screens were added; each route + * that needs more than a line belongs in its own builder rather than inline. + */ +private fun androidx.navigation.NavGraphBuilder.episodeRoute( + nav: androidx.navigation.NavHostController, + viewModel: ListenViewModel, + player: PlayerViewModel, +) { + composable("episode/{id}") { entry -> + val id = entry.arguments?.getString("id") + if (id == null) { + nav.popBackStack() + } else { + EpisodeScreen( + vm = viewModel, + player = player, + episodeId = id, + nav = SubscriptionNav( + openEpisode = { nav.navigate("episode/$it") }, + openPlayer = { nav.navigate("player") }, + openQueue = { nav.navigate("queue") }, + back = { nav.popBackStack() }, + ), + ) + } + } +} diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt index c8d71ea..8f3bc70 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -103,6 +103,7 @@ fun SubscriptionScreen( 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) }, @@ -161,6 +162,10 @@ private fun UnsubscribeDialog( /** Navigation out of this screen, bundled so the signature stays readable. */ data class SubscriptionNav( + // No default. A no-op default on a navigation callback compiles perfectly and ships a + // button that does nothing -- which is exactly what happened: tapping an episode title + // silently did nothing because one route forgot to pass this. + val openEpisode: (String) -> Unit, val openPlayer: () -> Unit, val openQueue: () -> Unit, val back: () -> Unit, diff --git a/app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt b/app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt new file mode 100644 index 0000000..b0eef17 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt @@ -0,0 +1,92 @@ +package org.terst.listen.data + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * Marking a back catalogue played. + * + * After an OPML import every episode of every show arrives unplayed, which is thousands of + * items you have in fact already heard. Marking them one at a time is not a real option. + */ +class BulkPlayedTest { + + private lateinit var dao: FakeEpisodeDao + + private fun episode(id: String, publishedAtMs: Long?, playedAtMs: Long? = null) = EpisodeEntity( + id = id, + subscriptionId = "s1", + guid = id, + title = "Episode $id", + mediaUrl = "https://cdn/$id.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + publishedAtMs = publishedAtMs, + playedAtMs = playedAtMs, + positionMs = if (id == "resumed") 90_000 else 0, + ) + + @Before + fun setUp() { + dao = FakeEpisodeDao() + dao.rows += listOf( + episode("newest", 500), + episode("pivot", 300), + episode("older", 200), + episode("oldest", 100), + ) + } + + private fun played(id: String) = dao.rows.first { it.id == id }.playedAtMs + + @Test + fun `marks the chosen episode and everything older`() = runTest { + dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999) + assertEquals(999L, played("pivot")) + assertEquals(999L, played("older")) + assertEquals(999L, played("oldest")) + } + + @Test + fun `leaves newer episodes alone`() = runTest { + dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999) + assertNull("a newer episode must not be marked", played("newest")) + } + + @Test + fun `does not touch another podcast`() = runTest { + dao.rows += episode("other", 100).copy(id = "other", subscriptionId = "s2") + dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999) + assertNull(dao.rows.first { it.id == "other" }.playedAtMs) + } + + /** Re-marking must not rewrite a date that already recorded when it was really finished. */ + @Test + fun `an already-played episode keeps its original date`() = runTest { + dao.rows += episode("done", 250, playedAtMs = 42) + dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999) + assertEquals(42L, dao.rows.first { it.id == "done" }.playedAtMs) + } + + /** + * Retention deletes played downloads, and it treats a non-zero position as "resumed, still + * in use". Bulk-marking clears positions so a swept-away back catalogue doesn't linger. + */ + @Test + fun `clears playback positions it marks`() = runTest { + dao.rows += episode("resumed", 150) + dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999) + assertEquals(0L, dao.rows.first { it.id == "resumed" }.positionMs) + } + + /** An undated episode has no place in a chronological sweep; leave it. */ + @Test + fun `undated episodes are left alone`() = runTest { + dao.rows += episode("undated", null) + dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999) + assertNull(dao.rows.first { it.id == "undated" }.playedAtMs) + } +} 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 1822ab9..59443c0 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -89,6 +89,16 @@ class FakeEpisodeDao : EpisodeDao { replace(id) { it.copy(playedAtMs = atMs, positionMs = 0) } } + override suspend fun markPlayedUpTo(subscriptionId: String, publishedAtMs: Long, atMs: Long) { + rows.replaceAll { row -> + val eligible = row.subscriptionId == subscriptionId && + row.publishedAtMs != null && + row.publishedAtMs!! <= publishedAtMs && + row.playedAtMs == null + if (eligible) row.copy(playedAtMs = atMs, positionMs = 0) else row + } + } + override suspend fun markUnplayed(id: String) { replace(id) { it.copy(playedAtMs = null) } } diff --git a/app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt b/app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt new file mode 100644 index 0000000..b3995a3 --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt @@ -0,0 +1,54 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Show notes are HTML in practice; rendered raw they are a wall of tags. */ +class StripHtmlTest { + + @Test + fun `removes tags`() { + assertEquals("Hello world", stripHtml("<p>Hello <b>world</b></p>")) + } + + @Test + fun `turns breaks and paragraph ends into newlines`() { + assertEquals("One\nTwo", stripHtml("One<br/>Two")) + assertEquals("One\nTwo", stripHtml("<p>One</p><p>Two</p>")) + } + + @Test + fun `decodes the entities that actually appear`() { + assertEquals("Rock & Roll", stripHtml("Rock & Roll")) + assertEquals("\"quoted\" and 'apostrophe'", stripHtml(""quoted" and 'apostrophe'")) + assertEquals("a b", stripHtml("a b")) + } + + @Test + fun `collapses the blank lines stripping leaves behind`() { + assertEquals("One\n\nTwo", stripHtml("<p>One</p><br/><br/><br/><p>Two</p>")) + } + + @Test + fun `leaves plain text alone`() { + assertEquals("Just words.", stripHtml("Just words.")) + } + + @Test + fun `handles an empty description`() { + assertEquals("", stripHtml("")) + assertEquals("", stripHtml("<p></p>")) + } + + /** Links are common in show notes; the text should survive even though the href cannot. */ + @Test + fun `keeps link text`() { + assertEquals("See the notes", stripHtml("""See <a href="https://x.com">the notes</a>""")) + } + + @Test + fun `does not leave stray angle brackets`() { + assertTrue(!stripHtml("<div><span>x</span></div>").contains("<")) + } +} |
