summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt30
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeActions.kt1
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt46
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt158
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt20
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt33
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt5
-rw-r--r--app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt92
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt10
-rw-r--r--app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt54
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("&nbsp;", " ")
+ .replace("&amp;", "&")
+ .replace("&lt;", "<")
+ .replace("&gt;", ">")
+ .replace("&quot;", "\"")
+ .replace("&#39;", "'")
+ .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 &amp; Roll"))
+ assertEquals("\"quoted\" and 'apostrophe'", stripHtml("&quot;quoted&quot; and &#39;apostrophe&#39;"))
+ assertEquals("a b", stripHtml("a&nbsp;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("<"))
+ }
+}