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/ui/EpisodeMenu.kt11
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeOpening.kt36
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt13
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt31
-rw-r--r--app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt30
-rw-r--r--app/src/test/java/org/terst/listen/ui/EpisodeOpeningTest.kt65
6 files changed, 156 insertions, 30 deletions
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
index dcdc4ab..01f2f82 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
@@ -7,7 +7,8 @@ import org.terst.listen.feed.Chapter
/** What the overflow menu needs to know about the open episode. */
data class OverflowContent(
val episode: EpisodeEntity,
- val chapters: List<Chapter>,
+ /** Loads the chapters when the entry is opened; nothing is fetched before that. */
+ val loadChapters: suspend () -> List<Chapter>,
val watchLink: String?,
val positionMs: Long,
)
@@ -22,11 +23,13 @@ data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String)
* Everything that used to be a button beside Play lives here, so nothing was deleted, only moved
* one tap away. Entries that do not apply are absent rather than disabled: a chapters entry on a
* show without chapters would advertise a feature that, across the owner's 61 subscriptions,
- * exactly one show has.
+ * exactly one show has. Whether an episode has chapters is only known after fetching them, so
+ * the entry is offered on the cheaper signal, a chapters URL; the dialog says so if the file
+ * turns out empty.
*/
object EpisodeMenu {
- fun items(episode: EpisodeEntity, hasChapters: Boolean, watchLink: String?): List<EpisodeMenuItem> = buildList {
+ fun items(episode: EpisodeEntity, watchLink: String?): List<EpisodeMenuItem> = buildList {
add(EpisodeMenuItem(EpisodeMenuAction.QUEUE, "Queue"))
add(EpisodeMenuItem(EpisodeMenuAction.DOWNLOAD, "Download"))
WaitingForServerText.publisherOffer(episode)?.let {
@@ -40,6 +43,6 @@ object EpisodeMenu {
)
if (watchLink != null) add(EpisodeMenuItem(EpisodeMenuAction.WATCH, "Watch"))
add(EpisodeMenuItem(EpisodeMenuAction.MARK_OLDER_PLAYED, "Mark this and older as played"))
- if (hasChapters) add(EpisodeMenuItem(EpisodeMenuAction.CHAPTERS, "Chapters"))
+ if (episode.chaptersUrl != null) add(EpisodeMenuItem(EpisodeMenuAction.CHAPTERS, "Chapters"))
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOpening.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOpening.kt
new file mode 100644
index 0000000..0bc7afa
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeOpening.kt
@@ -0,0 +1,36 @@
+package org.terst.listen.ui
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.feed.Chapter
+
+/** What the episode screen reads when it opens, and the one thing it reads later. */
+interface EpisodeScreenSource {
+ suspend fun episode(id: String): EpisodeEntity?
+ suspend fun midrollRanges(id: String): List<EpisodeMidrollEntity>
+ suspend fun speedFor(episode: EpisodeEntity): Float
+ /** Fetched (and cached by the source) on demand, when the chapters entry is opened. */
+ suspend fun chapters(episode: EpisodeEntity): List<Chapter>
+}
+
+class OpenedEpisode(val episode: EpisodeEntity, val ranges: List<EpisodeMidrollEntity>, val speed: Float)
+
+/**
+ * What the screen does on open, kept apart from the composable so a test can prove what it
+ * does not do: chapters live in the overflow menu, and across the owner's 61 shows exactly one
+ * publishes them, so they are fetched when the chapters entry is opened and not before.
+ */
+object EpisodeOpening {
+ suspend fun open(id: String, source: EpisodeScreenSource): OpenedEpisode? {
+ val episode = source.episode(id) ?: return null
+ return OpenedEpisode(episode, source.midrollRanges(id), source.speedFor(episode))
+ }
+
+ /** Fetched (and cached by the source) on demand, when the chapters entry is opened. */
+ suspend fun chapters(episode: EpisodeEntity, source: EpisodeScreenSource): List<Chapter> = source.chapters(episode)
+}
+
+/** The top bar carries the show (navigational context); the episode's own title is in the body. */
+object EpisodeTitles {
+ fun topBar(showTitle: String?): String = showTitle?.takeIf { it.isNotBlank() } ?: "Episode"
+}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
index f832be3..e872aed 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
@@ -23,6 +23,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.download.WaitingForServerText
+import org.terst.listen.feed.Chapter
/**
* The top bar's `⋮`: every secondary action, one tap away. What it lists is [EpisodeMenu]'s call.
@@ -50,7 +52,7 @@ fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) {
Icon(Icons.Default.MoreVert, contentDescription = "More actions")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
- EpisodeMenu.items(content.episode, content.chapters.isNotEmpty(), content.watchLink).forEach { item ->
+ EpisodeMenu.items(content.episode, content.watchLink).forEach { item ->
DropdownMenuItem(
text = { Text(item.label) },
onClick = {
@@ -107,13 +109,18 @@ private fun PublisherCopyDialog(episode: EpisodeEntity, confirm: () -> Unit, dis
*/
@Composable
private fun ChaptersDialog(content: OverflowContent, onSeek: (Long) -> Unit, dismiss: () -> Unit) {
- val currentIndex = content.chapters.indexOfLast { it.startMs <= content.positionMs }
+ // Null while loading. The fetch starts here, when the dialog opens, and not with the screen.
+ val loaded by produceState<List<Chapter>?>(null, content.episode.id) { value = content.loadChapters() }
+ val chapters = loaded.orEmpty()
+ val currentIndex = chapters.indexOfLast { it.startMs <= content.positionMs }
AlertDialog(
onDismissRequest = dismiss,
title = { Text("Chapters") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
- content.chapters.forEachIndexed { index, chapter ->
+ if (loaded == null) Text("Loading…", style = MaterialTheme.typography.bodySmall)
+ else if (chapters.isEmpty()) Text("No chapters available.", style = MaterialTheme.typography.bodySmall)
+ chapters.forEachIndexed { index, chapter ->
Row(
Modifier.fillMaxWidth()
.clickable { onSeek(chapter.startMs); dismiss() }
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
index c77d997..ac0b99f 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -63,21 +63,20 @@ fun EpisodeScreen(
) {
var episode by remember { mutableStateOf<EpisodeEntity?>(null) }
var ranges by remember { mutableStateOf<List<EpisodeMidrollEntity>>(emptyList()) }
- var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) }
var speed by remember { mutableFloatStateOf(PlaybackSpeed.DEFAULT) }
LaunchedEffect(episodeId) {
- val loaded = vm.episode(episodeId)
- episode = loaded
- ranges = vm.midrollRanges(episodeId)
- if (loaded != null) {
- speed = player.speedFor(loaded)
- chapters = player.chaptersFor(loaded)
+ EpisodeOpening.open(episodeId, ScreenSource(vm, player))?.let {
+ episode = it.episode
+ ranges = it.ranges
+ speed = it.speed
}
}
val scope = rememberCoroutineScope()
val rules by vm.rules.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
+ val showTitle = vm.subscriptions.collectAsState().value
+ .firstOrNull { it.id == episode?.subscriptionId }?.title
val current = episode
val hooks = EpisodeHooks(
onSpeed = { speed = it },
@@ -86,9 +85,11 @@ fun EpisodeScreen(
)
Scaffold(
topBar = {
- EpisodeTopBar(current?.title, nav.back) {
+ EpisodeTopBar(EpisodeTitles.topBar(showTitle), nav.back) {
current?.let { shown ->
val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs
+ val chapters: suspend () -> List<Chapter> =
+ { EpisodeOpening.chapters(shown, ScreenSource(vm, player)) }
EpisodeOverflow(
OverflowContent(shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs),
episodeActions(vm, player, shown, nowPlaying, hooks),
@@ -119,6 +120,14 @@ fun EpisodeScreen(
}
}
+private class ScreenSource(private val vm: ListenViewModel, private val player: PlayerViewModel) :
+ EpisodeScreenSource {
+ override suspend fun episode(id: String) = vm.episode(id)
+ override suspend fun midrollRanges(id: String) = vm.midrollRanges(id)
+ override suspend fun speedFor(episode: EpisodeEntity) = player.speedFor(episode)
+ override suspend fun chapters(episode: EpisodeEntity) = player.chaptersFor(episode)
+}
+
/** How the actions report back to the screen's own state. */
private class EpisodeHooks(
val onSpeed: (Float) -> Unit,
@@ -200,7 +209,7 @@ private fun EpisodeDetail(
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Column(Modifier.weight(1f)) {
- Text(episode.title, style = MaterialTheme.typography.titleMedium, maxLines = 3, overflow = TextOverflow.Ellipsis)
+ Text(episode.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
}
}
@@ -219,9 +228,9 @@ private fun EpisodeDetail(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun EpisodeTopBar(title: String?, onBack: () -> Unit, overflow: @Composable () -> Unit) {
+private fun EpisodeTopBar(title: String, onBack: () -> Unit, overflow: @Composable () -> Unit) {
TopAppBar(
- title = { Text(title ?: "Episode", maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
index ca20319..7ff7711 100644
--- a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
+++ b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
@@ -16,13 +16,18 @@ import org.terst.listen.ui.EpisodeMenuAction.WATCH
class EpisodeMenuTest {
- private fun episode(state: DownloadState = DownloadState.NONE, playedAtMs: Long? = null) = EpisodeEntity(
+ private fun episode(
+ state: DownloadState = DownloadState.NONE,
+ playedAtMs: Long? = null,
+ chaptersUrl: String? = null,
+ ) = EpisodeEntity(
id = "e1", subscriptionId = "s1", guid = "g", title = "One", mediaUrl = "https://cdn/1.mp3",
mediaType = "audio/mpeg", discoveredAtMs = 0, downloadState = state, playedAtMs = playedAtMs,
+ chaptersUrl = chaptersUrl,
)
- private fun actions(e: EpisodeEntity, chapters: Boolean = false, link: String? = null) =
- EpisodeMenu.items(e, chapters, link).map { it.action }
+ private fun actions(e: EpisodeEntity, link: String? = null) =
+ EpisodeMenu.items(e, link).map { it.action }
@Test
fun `an ordinary episode offers the everyday actions and nothing conditional`() {
@@ -31,7 +36,7 @@ class EpisodeMenuTest {
@Test
fun `a waiting episode offers the publisher's copy with its existing label`() {
- val items = EpisodeMenu.items(episode(DownloadState.WAITING_FOR_SERVER), false, null)
+ val items = EpisodeMenu.items(episode(DownloadState.WAITING_FOR_SERVER), null)
assertEquals("Download from publisher instead", items.single { it.action == PUBLISHER_COPY }.label)
}
@@ -48,29 +53,30 @@ class EpisodeMenuTest {
}
@Test
- fun `chapters appear only when the episode has some`() {
- assertFalse(CHAPTERS in actions(episode(), chapters = false))
- assertTrue(CHAPTERS in actions(episode(), chapters = true))
+ fun `chapters are offered when the episode has a chapters url, without fetching anything`() {
+ assertFalse(CHAPTERS in actions(episode()))
+ assertTrue(CHAPTERS in actions(episode(chaptersUrl = "https://cdn/1.json")))
}
@Test
fun `watch appears only for an episode with a link`() {
- assertFalse(WATCH in actions(episode(), link = null))
- assertTrue(WATCH in actions(episode(), link = "https://youtu.be/x"))
+ assertFalse(WATCH in actions(episode(), null))
+ assertTrue(WATCH in actions(episode(), "https://youtu.be/x"))
}
@Test
fun `the played entry says what tapping it will do`() {
- assertEquals("Played", EpisodeMenu.items(episode(), false, null).single { it.action == TOGGLE_PLAYED }.label)
+ assertEquals("Played", EpisodeMenu.items(episode(), null).single { it.action == TOGGLE_PLAYED }.label)
assertEquals(
"Unplayed",
- EpisodeMenu.items(episode(playedAtMs = 5L), false, null).single { it.action == TOGGLE_PLAYED }.label,
+ EpisodeMenu.items(episode(playedAtMs = 5L), null).single { it.action == TOGGLE_PLAYED }.label,
)
}
@Test
fun `the wording the buttons had is kept`() {
- val labels = EpisodeMenu.items(episode(), true, "https://youtu.be/x").associate { it.action to it.label }
+ val withEverything = episode(chaptersUrl = "https://cdn/1.json")
+ val labels = EpisodeMenu.items(withEverything, "https://youtu.be/x").associate { it.action to it.label }
assertEquals("Queue", labels[QUEUE])
assertEquals("Download", labels[DOWNLOAD])
assertEquals("Watch", labels[WATCH])
diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeOpeningTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeOpeningTest.kt
new file mode 100644
index 0000000..7d3f51a
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/EpisodeOpeningTest.kt
@@ -0,0 +1,65 @@
+package org.terst.listen.ui
+
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.feed.Chapter
+
+class EpisodeOpeningTest {
+
+ private val episode = EpisodeEntity(
+ id = "e1", subscriptionId = "s1", guid = "g", title = "One", mediaUrl = "https://cdn/1.mp3",
+ mediaType = "audio/mpeg", discoveredAtMs = 0, chaptersUrl = "https://cdn/1.json",
+ )
+
+ private class RecordingSource(private val episode: EpisodeEntity?, val fetched: List<Chapter> = emptyList()) :
+ EpisodeScreenSource {
+ var chapterCalls = 0
+ override suspend fun episode(id: String) = episode
+ override suspend fun midrollRanges(id: String) = emptyList<EpisodeMidrollEntity>()
+ override suspend fun speedFor(episode: EpisodeEntity) = 1.25f
+ override suspend fun chapters(episode: EpisodeEntity): List<Chapter> {
+ chapterCalls++
+ return fetched
+ }
+ }
+
+ @Test
+ fun `opening the screen does not fetch chapters even when the episode has a chapters url`() = runBlocking {
+ val source = RecordingSource(episode)
+ val opened = EpisodeOpening.open("e1", source)
+ assertNotNull(opened)
+ assertEquals(1.25f, opened!!.speed)
+ assertEquals(0, source.chapterCalls)
+ }
+
+ @Test
+ fun `opening a missing episode yields nothing and fetches nothing`() = runBlocking {
+ val source = RecordingSource(null)
+ assertNull(EpisodeOpening.open("nope", source))
+ assertEquals(0, source.chapterCalls)
+ }
+
+ @Test
+ fun `opening the chapters entry fetches them once`() = runBlocking {
+ val list = listOf(Chapter(startMs = 0, title = "Intro", inToc = true))
+ val source = RecordingSource(episode, list)
+ assertEquals(list, EpisodeOpening.chapters(episode, source))
+ assertEquals(1, source.chapterCalls)
+ }
+
+ @Test
+ fun `the top bar names the show`() {
+ assertEquals("Darknet Diaries", EpisodeTitles.topBar("Darknet Diaries"))
+ }
+
+ @Test
+ fun `the top bar falls back to a generic label when the show is unknown or blank`() {
+ assertEquals("Episode", EpisodeTitles.topBar(null))
+ assertEquals("Episode", EpisodeTitles.topBar(" "))
+ }
+}