diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-19 09:40:35 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-19 09:40:35 +0000 |
| commit | f087b8d15f38bc5500aa2ee986865557460ec89c (patch) | |
| tree | 29c06c756cffb01cbe44070d3dc829a8451bb280 /app | |
| parent | 2329c92fb989b12224a9d1b5fb22e7e0a51f1b45 (diff) | |
Episode screen: show name in the top bar, chapters fetched lazily
The top bar now names the show (from the already-loaded subscriptions
list, "Episode" if unknown) and the body keeps the episode title, wrapping
to two lines. Chapters are no longer fetched when the screen opens; the
overflow offers the entry whenever a chapters URL exists and the dialog
fetches (cached) on open, saying so if the file is empty. Playback's
ad-chapter path (PlaybackAdvancer/PlaybackSources) is untouched.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
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(" ")) + } +} |
