summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-19 09:31:21 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-19 09:31:21 +0000
commit2329c92fb989b12224a9d1b5fb22e7e0a51f1b45 (patch)
tree4cbbdca4458f448b88c0ab9ddcaf4ef73739171f /app/src
parent10abc31e8220b39231cac31ee96109071c56c950 (diff)
Make the episode screen the player and delete the separate player screen
Opening an episode now puts you in its transport at the stored position; pressing play changes nothing about the layout. One primary control (Play/Resume/Pause) with back/forward, a speed chip that opens the four offered speeds (stored 1.5/1.75 still play and are named on the chip), and everything secondary behind a top-bar overflow: queue, download, played, watch, mark-older, chapters, and the publisher-copy action (behind a dialog carrying its warning). Description shows about two lines, the rest on tap. The now-playing bar, library bar, queue, share receiver and nav graph all open the playing episode's screen. PlayerViewModel stays the single owner of playback state; no playback decision moved into the UI. PlayerScreen.kt and WatchButton.kt are deleted. Coverage drop, if any, is from removing covered dead code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt33
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt27
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt50
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt45
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt143
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt345
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt173
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt92
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryNav.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt28
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt4
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerBar.kt16
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerScreen.kt247
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt108
-rw-r--r--app/src/main/java/org/terst/listen/ui/QueueScreen.kt3
-rw-r--r--app/src/main/java/org/terst/listen/ui/SpeedChoices.kt23
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt3
-rw-r--r--app/src/main/java/org/terst/listen/ui/Transport.kt21
-rw-r--r--app/src/main/java/org/terst/listen/ui/WatchButton.kt29
-rw-r--r--app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt58
-rw-r--r--app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt80
-rw-r--r--app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt44
-rw-r--r--app/src/test/java/org/terst/listen/ui/TransportTest.kt38
25 files changed, 953 insertions, 663 deletions
diff --git a/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt b/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt
new file mode 100644
index 0000000..2569408
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt
@@ -0,0 +1,33 @@
+package org.terst.listen.ui
+
+/**
+ * How much of a description shows before the listener asks for the rest.
+ *
+ * A show with long notes used to push the transport off the screen, so the notes start as about
+ * two lines. The rule is line count *and* a character budget: stripped HTML is often one long
+ * paragraph, which two newline-delimited lines would not limit at all.
+ */
+object DescriptionPreview {
+
+ private const val MAX_LINES = 2
+
+ /** Roughly two lines of body text on a phone. */
+ const val MAX_CHARS = 140
+
+ private const val ELLIPSIS = "…"
+
+ data class Preview(val text: String, val truncated: Boolean)
+
+ fun collapse(text: String): Preview {
+ val lines = text.lines().filter { it.isNotBlank() }
+ val kept = lines.take(MAX_LINES).joinToString("\n")
+ val cutByLines = lines.size > MAX_LINES
+ if (kept.length <= MAX_CHARS) {
+ return if (cutByLines) Preview(kept + ELLIPSIS, true) else Preview(kept, false)
+ }
+ val head = kept.take(MAX_CHARS)
+ // Back to the last space so a word is not sliced, unless there is no space to back to.
+ val atWord = head.lastIndexOf(' ').takeIf { it > 0 }?.let { head.take(it) } ?: head
+ return Preview(atWord.trimEnd() + ELLIPSIS, true)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt
new file mode 100644
index 0000000..a3d9ea3
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt
@@ -0,0 +1,27 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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.text.style.TextOverflow
+
+/** About two lines of the notes; a tap shows the rest, and another tap folds it back. */
+@Composable
+fun ExpandableDescription(text: String) {
+ val preview = DescriptionPreview.collapse(text)
+ var expanded by remember(text) { mutableStateOf(false) }
+ val showAll = expanded || !preview.truncated
+ Text(
+ if (showAll) text else preview.text,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = if (showAll) Int.MAX_VALUE else 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = if (preview.truncated) Modifier.clickable { expanded = !expanded } else Modifier,
+ )
+}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
index a108e04..06b9ef3 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
@@ -1,59 +1,25 @@
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. */
+/** The episode screen's actions, bundled so the signature stays readable. */
data class EpisodeDetailActions(
- val play: () -> Unit,
val enqueue: () -> Unit,
val download: () -> Unit,
/**
* Takes the publisher's copy of a waiting episode. Deliberately its own action rather than
- * a mode of [download], so it can only be reached from the button that states the trade.
+ * a mode of [download], so it can only be reached through the confirmation that states the trade.
*/
- val downloadFromPublisher: () -> Unit = {},
+ val downloadFromPublisher: () -> Unit,
val togglePlayed: () -> Unit,
val markOlderPlayed: () -> Unit,
/** Play/pause when this episode is the one playing; starts it when it is not. */
- val togglePlayPause: () -> Unit = {},
+ val togglePlayPause: () -> Unit,
/**
* Where the scrubber was dragged to. Seeks the player when this episode is the one playing,
* and otherwise moves where Play will start from -- the screen decides which, because only
* it knows whether the drag is fighting a live position tick.
*/
- val seekTo: (Long) -> Unit = {},
+ val seekTo: (Long) -> Unit,
+ /** Skips back or forward by a step, from wherever this episode currently is. */
+ val skip: (deltaMs: Long) -> Unit,
+ val setSpeed: (Float) -> Unit,
)
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
new file mode 100644
index 0000000..dcdc4ab
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt
@@ -0,0 +1,45 @@
+package org.terst.listen.ui
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.download.WaitingForServerText
+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>,
+ val watchLink: String?,
+ val positionMs: Long,
+)
+
+enum class EpisodeMenuAction { QUEUE, DOWNLOAD, PUBLISHER_COPY, TOGGLE_PLAYED, WATCH, MARK_OLDER_PLAYED, CHAPTERS }
+
+data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String)
+
+/**
+ * What the episode screen's overflow menu holds for a given episode.
+ *
+ * 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.
+ */
+object EpisodeMenu {
+
+ fun items(episode: EpisodeEntity, hasChapters: Boolean, watchLink: String?): List<EpisodeMenuItem> = buildList {
+ add(EpisodeMenuItem(EpisodeMenuAction.QUEUE, "Queue"))
+ add(EpisodeMenuItem(EpisodeMenuAction.DOWNLOAD, "Download"))
+ WaitingForServerText.publisherOffer(episode)?.let {
+ add(EpisodeMenuItem(EpisodeMenuAction.PUBLISHER_COPY, it.label))
+ }
+ add(
+ EpisodeMenuItem(
+ EpisodeMenuAction.TOGGLE_PLAYED,
+ if (episode.playedAtMs == null) "Played" else "Unplayed",
+ ),
+ )
+ 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"))
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
new file mode 100644
index 0000000..f832be3
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt
@@ -0,0 +1,143 @@
+package org.terst.listen.ui
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.download.WaitingForServerText
+
+/**
+ * The top bar's `⋮`: every secondary action, one tap away. What it lists is [EpisodeMenu]'s call.
+ *
+ * Two entries open a dialog rather than acting at once. The publisher's copy states a trade that
+ * has to be read before it is made, and the chapter list is a list, not an action.
+ */
+@Composable
+fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) {
+ var open by remember { mutableStateOf(false) }
+ var dialog by remember { mutableStateOf<EpisodeMenuAction?>(null) }
+ val context = LocalContext.current
+ Box {
+ IconButton(onClick = { open = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "More actions")
+ }
+ DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
+ EpisodeMenu.items(content.episode, content.chapters.isNotEmpty(), content.watchLink).forEach { item ->
+ DropdownMenuItem(
+ text = { Text(item.label) },
+ onClick = {
+ open = false
+ when (item.action) {
+ EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY -> dialog = item.action
+ else -> perform(item.action, actions, content.watchLink, context)
+ }
+ },
+ )
+ }
+ }
+ }
+ when (dialog) {
+ EpisodeMenuAction.CHAPTERS -> ChaptersDialog(content, actions.seekTo) { dialog = null }
+ EpisodeMenuAction.PUBLISHER_COPY ->
+ PublisherCopyDialog(content.episode, actions.downloadFromPublisher) { dialog = null }
+ else -> Unit
+ }
+}
+
+private fun perform(action: EpisodeMenuAction, actions: EpisodeDetailActions, watchLink: String?, context: Context) {
+ when (action) {
+ EpisodeMenuAction.QUEUE -> actions.enqueue()
+ EpisodeMenuAction.DOWNLOAD -> actions.download()
+ EpisodeMenuAction.TOGGLE_PLAYED -> actions.togglePlayed()
+ EpisodeMenuAction.MARK_OLDER_PLAYED -> actions.markOlderPlayed()
+ // Not restricted to a package: whichever app claims the link handles it, falling back
+ // to a browser.
+ EpisodeMenuAction.WATCH -> watchLink?.let {
+ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, it.toUri())) }
+ }
+ EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY -> Unit
+ }
+}
+
+/** The way out of a wait and what it costs, together; wording is [WaitingForServerText]'s. */
+@Composable
+private fun PublisherCopyDialog(episode: EpisodeEntity, confirm: () -> Unit, dismiss: () -> Unit) {
+ val offer = WaitingForServerText.publisherOffer(episode) ?: return
+ AlertDialog(
+ onDismissRequest = dismiss,
+ text = { Text(offer.warning) },
+ confirmButton = { TextButton(onClick = { dismiss(); confirm() }) { Text(offer.label) } },
+ dismissButton = { TextButton(onClick = dismiss) { Text("Cancel") } },
+ )
+}
+
+/**
+ * The episode's chapters; tapping one seeks there and closes the list.
+ *
+ * Kept off the main screen on purpose: across the owner's 61 subscriptions exactly one show
+ * publishes chapters, so a permanent list would compete with the timeline for space it rarely earns.
+ */
+@Composable
+private fun ChaptersDialog(content: OverflowContent, onSeek: (Long) -> Unit, dismiss: () -> Unit) {
+ val currentIndex = content.chapters.indexOfLast { it.startMs <= content.positionMs }
+ AlertDialog(
+ onDismissRequest = dismiss,
+ title = { Text("Chapters") },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ content.chapters.forEachIndexed { index, chapter ->
+ Row(
+ Modifier.fillMaxWidth()
+ .clickable { onSeek(chapter.startMs); dismiss() }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ formatClock(chapter.startMs),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(end = 12.dp),
+ )
+ Text(
+ chapter.title ?: "Chapter ${index + 1}",
+ style = if (index == currentIndex) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ // Marked so a skipped section is explained rather than just vanishing.
+ if (!chapter.inToc) Text("break", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = dismiss) { Text("Close") } },
+ )
+}
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 21a9b5b..c77d997 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -1,16 +1,10 @@
package org.terst.listen.ui
-import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -18,16 +12,11 @@ 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.material.icons.filled.Pause
-import androidx.compose.material.icons.filled.PlayArrow
-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.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -39,34 +28,30 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
-import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.playbackDurationMs
+import org.terst.listen.feed.Chapter
import org.terst.listen.feed.WatchLink
-import org.terst.listen.fingerprint.BreakTrack
import org.terst.listen.fingerprint.EpisodeSkips
import org.terst.listen.fingerprint.SkipSummary
import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlaybackSpeed
/**
- * One episode in full.
+ * One episode in full, and the player.
*
- * 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.
+ * There is no separate player screen: opening an episode puts you in its transport at the
+ * position you left, and pressing play changes nothing about the layout. Playback state is read
+ * from [PlayerViewModel], which already owns it; this screen holds only what describes the open
+ * episode. Every playback decision -- completion, queue advance, outro, ad skipping -- stays in
+ * the service, so a button here only ever asks the player to start, pause or seek.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,27 +63,47 @@ 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) {
- episode = vm.episode(episodeId)
+ val loaded = vm.episode(episodeId)
+ episode = loaded
ranges = vm.midrollRanges(episodeId)
+ if (loaded != null) {
+ speed = player.speedFor(loaded)
+ chapters = player.chaptersFor(loaded)
+ }
}
val scope = rememberCoroutineScope()
val rules by vm.rules.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
val current = episode
+ val hooks = EpisodeHooks(
+ onSpeed = { speed = it },
+ onEpisode = { episode = it },
+ reload = { scope.launch { episode = vm.episode(episodeId) } },
+ )
Scaffold(
- topBar = { EpisodeTopBar(current?.title, nav.back) },
- bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) },
+ topBar = {
+ EpisodeTopBar(current?.title, nav.back) {
+ current?.let { shown ->
+ val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs
+ EpisodeOverflow(
+ OverflowContent(shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs),
+ episodeActions(vm, player, shown, nowPlaying, hooks),
+ )
+ }
+ }
+ },
+ // The playing episode's own screen already has the transport, so the bar steps aside
+ // there -- two sets of play controls on one screen would be the drift this merge removed.
+ bottomBar = { PlayerBar(player, nav.openEpisode, nav.openQueue, hideBarFor = episodeId) },
) { padding ->
if (current == null) {
Text("Episode not found", Modifier.padding(padding).padding(24.dp))
return@Scaffold
}
- // Whether the transport controls drive the player or just move a stored position. An
- // episode can be open on this screen while something else is playing, and a scrubber
- // that silently drove the wrong episode would be worse than one that did nothing.
- val isPlayingThis = nowPlaying.episodeId == current.id
EpisodeDetail(
episode = current,
detail = episodeDetailState(
@@ -106,37 +111,74 @@ fun EpisodeScreen(
ruleName = rules.firstOrNull { rule -> rule.id == current.appliedRuleId }?.name,
ranges = ranges,
nowPlaying = nowPlaying,
+ speed = speed,
),
modifier = Modifier.padding(padding),
- actions = EpisodeDetailActions(
- play = { player.play(current); nav.openPlayer() },
- enqueue = { vm.enqueue(current.id) },
- download = { vm.download(current) },
- // Re-reads afterwards, or the screen keeps offering the choice it just acted on.
- downloadFromPublisher = { vm.downloadFromPublisher(current) { scope.launch { episode = vm.episode(episodeId) } } },
- 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) },
- togglePlayPause = {
- if (isPlayingThis) player.togglePlayPause() else player.play(current)
- },
- seekTo = { positionMs ->
- // Seeking the player only works for the item it has loaded; for any other
- // episode the drag is a statement about where Play should begin.
- if (isPlayingThis) {
- player.seekTo(positionMs)
- } else {
- vm.setPosition(current.id, positionMs)
- episode = current.copy(positionMs = positionMs)
- }
- },
- ),
+ actions = episodeActions(vm, player, current, nowPlaying, hooks),
)
}
}
+/** How the actions report back to the screen's own state. */
+private class EpisodeHooks(
+ val onSpeed: (Float) -> Unit,
+ val onEpisode: (EpisodeEntity) -> Unit,
+ val reload: () -> Unit,
+)
+
+/**
+ * Wires the screen's buttons to the view models.
+ *
+ * Whether a control drives the player or just moves a stored position depends on whether this
+ * episode is the one playing: an episode can be open while something else plays, and a scrubber
+ * that silently drove the wrong episode would be worse than one that did nothing.
+ */
+private fun episodeActions(
+ vm: ListenViewModel,
+ player: PlayerViewModel,
+ current: EpisodeEntity,
+ nowPlaying: NowPlaying,
+ hooks: EpisodeHooks,
+): EpisodeDetailActions {
+ val isPlayingThis = nowPlaying.episodeId == current.id
+ val seekTo = { positionMs: Long ->
+ // Seeking the player only works for the item it has loaded; for any other
+ // episode the drag is a statement about where Play should begin.
+ if (isPlayingThis) {
+ player.seekTo(positionMs)
+ } else {
+ vm.setPosition(current.id, positionMs)
+ hooks.onEpisode(current.copy(positionMs = positionMs))
+ }
+ }
+ return EpisodeDetailActions(
+ enqueue = { vm.enqueue(current.id) },
+ download = { vm.download(current) },
+ // Re-reads afterwards, or the screen keeps offering the choice it just acted on.
+ downloadFromPublisher = {
+ vm.downloadFromPublisher(current, hooks.reload)
+ },
+ togglePlayed = {
+ if (current.playedAtMs == null) vm.markPlayed(current.id) else vm.markUnplayed(current.id)
+ hooks.onEpisode(current.copy(playedAtMs = if (current.playedAtMs == null) 1L else null))
+ },
+ markOlderPlayed = { vm.markPlayedUpTo(current) },
+ togglePlayPause = { if (isPlayingThis) player.togglePlayPause() else player.play(current) },
+ seekTo = seekTo,
+ skip = { delta ->
+ when {
+ !isPlayingThis -> seekTo(Transport.stepped(current.positionMs, delta, current.playbackDurationMs))
+ delta < 0 -> player.skipBack()
+ else -> player.skipForward()
+ }
+ },
+ setSpeed = { chosen ->
+ hooks.onSpeed(chosen)
+ player.setSpeedFor(current, chosen)
+ },
+ )
+}
+
@Composable
private fun EpisodeDetail(
episode: EpisodeEntity,
@@ -158,7 +200,7 @@ private fun EpisodeDetail(
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Column(Modifier.weight(1f)) {
- Text(episode.title, style = MaterialTheme.typography.titleMedium)
+ Text(episode.title, style = MaterialTheme.typography.titleMedium, maxLines = 3, overflow = TextOverflow.Ellipsis)
Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
}
}
@@ -167,45 +209,17 @@ private fun EpisodeDetail(
SkipReadout(detail.skips, detail.playback.breaks)
- 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")
- }
- WatchButton(WatchLink.at(episode.mediaUrl, episode.positionMs), "Watch")
- }
-
- // The way out of a wait, and what it costs, side by side. Wording is SkipSummary's so a
- // test pins it.
- WaitingForServerText.publisherOffer(episode)?.let { offer ->
- Text(offer.warning, style = MaterialTheme.typography.bodySmall)
- OutlinedButton(onClick = actions.downloadFromPublisher, modifier = Modifier.fillMaxWidth()) {
- Text(offer.label)
- }
- }
-
- // 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")
- }
+ EpisodeTransport(detail.playback, detail.speed, actions)
RuleAttribution(episode, detail.ruleName)
- episode.description?.let {
- Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium)
- }
+ episode.description?.let { ExpandableDescription(stripHtml(it)) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun EpisodeTopBar(title: String?, onBack: () -> Unit) {
+private fun EpisodeTopBar(title: String?, onBack: () -> Unit, overflow: @Composable () -> Unit) {
TopAppBar(
title = { Text(title ?: "Episode", maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
@@ -213,6 +227,7 @@ private fun EpisodeTopBar(title: String?, onBack: () -> Unit) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
+ actions = { overflow() },
)
}
@@ -221,6 +236,8 @@ data class EpisodeDetailState(
val ruleName: String?,
val skips: EpisodeSkips,
val playback: EpisodePlayback,
+ /** What this episode plays at: a rule's choice, else the show's, else normal. */
+ val speed: Float,
)
/**
@@ -235,10 +252,13 @@ private fun episodeDetailState(
ruleName: String?,
ranges: List<EpisodeMidrollEntity>,
nowPlaying: NowPlaying,
+ speed: Float,
): EpisodeDetailState {
// Whether the transport controls drive the player or just describe a stored position.
val isPlayingThis = nowPlaying.episodeId == episode.id
- val durationMs = if (isPlayingThis) nowPlaying.durationMs else episode.playbackDurationMs
+ // A player that has the episode loaded but has not measured it yet (or failed to) reports
+ // no length; falling back to the stored one keeps the marks and the clock on screen.
+ val durationMs = nowPlaying.durationMs?.takeIf { isPlayingThis && it > 0 } ?: episode.playbackDurationMs
return EpisodeDetailState(
ruleName = ruleName,
skips = SkipSummary.episode(episode, ranges),
@@ -248,6 +268,7 @@ private fun episodeDetailState(
isPlaying = isPlayingThis && nowPlaying.isPlaying,
breaks = SkipSummary.breakMarkers(ranges, durationMs),
),
+ speed = speed,
)
}
@@ -261,138 +282,6 @@ data class EpisodePlayback(
)
/**
- * This episode's position, with the detected ad breaks marked on it.
- *
- * Separate from the player screen's scrubber on purpose: that one always drives whatever is
- * playing, and this one has to work for an episode that is merely open -- where a drag sets
- * where Play will begin rather than seeking anything. The drag rule is the same, though: a
- * finger should not fight the live position tick, so the local value only tracks the drag and
- * the seek fires once on release.
- */
-@Composable
-private fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) {
- val duration = playback.durationMs?.takeIf { it > 0 } ?: 0L
- var dragValue by remember { mutableFloatStateOf(-1f) }
- val shown = if (dragValue >= 0f) dragValue else playback.positionMs.toFloat()
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = actions.togglePlayPause) {
- Icon(
- if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
- contentDescription = if (playback.isPlaying) "Pause" else "Play",
- )
- }
- Column(Modifier.weight(1f)) {
- Box {
- Slider(
- value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)),
- onValueChange = { dragValue = it },
- onValueChangeFinished = {
- actions.seekTo(dragValue.toLong())
- dragValue = -1f
- },
- valueRange = 0f..duration.toFloat().coerceAtLeast(1f),
- enabled = duration > 0,
- )
- }
- BreakMarks(playback.breaks, duration) { actions.seekTo(it) }
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall)
- Text(formatClock(duration), style = MaterialTheme.typography.bodySmall)
- }
- }
- }
-}
-
-/**
- * The detected breaks as bands on a strip under the slider, advertising in one colour and the
- * show's own audio (intro, cue, outro) in another, decided by [SkipSummary.isAdvertising] rather
- * than by comparing against [SkipSummary.describeKind]'s display string -- a renamed label must
- * not silently invert which colour means advertising. A kind this version does not recognise
- * draws as advertising too, matching what playback does with it.
- *
- * Tapping a band names it and seeks to its start. The strip is its own, taller target rather than
- * a hairline behind the slider, because a break is a few pixels wide and a finger is not; what a
- * tap hits is decided by [BreakTrack.markAt], so the composable only converts pixels.
- */
-@Composable
-private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, onSeek: (Long) -> Unit) {
- if (breaks.isEmpty()) return
- var picked by remember(breaks) { mutableStateOf<SkipSummary.BreakMarker?>(null) }
- val adColour = MaterialTheme.colorScheme.tertiary
- val furnitureColour = MaterialTheme.colorScheme.secondary
- val trackColour = MaterialTheme.colorScheme.surfaceVariant
- val outlineColour = MaterialTheme.colorScheme.onSurface
- Canvas(
- Modifier
- .fillMaxWidth()
- .height(BREAK_STRIP_HEIGHT)
- .pointerInput(breaks, durationMs) {
- detectTapGestures { tap ->
- val width = size.width.toFloat()
- val usable = (width - THUMB_RADIUS_PX * 2).coerceAtLeast(1f)
- val hit = BreakTrack.markAt(
- breaks,
- BreakTrack.trackFraction(tap.x, width, THUMB_RADIUS_PX),
- slop = TAP_SLOP_PX / usable,
- )
- picked = hit
- hit?.let { onSeek(BreakTrack.markStartMs(it, durationMs)) }
- }
- },
- ) {
- // Inset by the thumb's radius at each end: the slider's track is laid out inside it, so
- // marks drawn edge to edge would sit a thumb-width off the positions they describe.
- val inset = THUMB_RADIUS_PX
- val usable = (size.width - inset * 2).coerceAtLeast(0f)
- val bandTop = (size.height - BREAK_BAND_PX) / 2
- drawRect(trackColour, Offset(inset, bandTop), Size(usable, BREAK_BAND_PX))
- breaks.forEach { mark ->
- val left = inset + usable * mark.start
- val right = inset + usable * mark.end
- val topLeft = Offset(left, bandTop)
- val bandSize = Size((right - left).coerceAtLeast(MIN_BREAK_MARK_PX), BREAK_BAND_PX)
- drawRect(if (SkipSummary.isAdvertising(mark.kind)) adColour else furnitureColour, topLeft, bandSize)
- if (mark == picked) drawRect(outlineColour, topLeft, bandSize, style = Stroke(width = 2f))
- }
- }
- picked?.let {
- Text(BreakTrack.describeMark(it, durationMs), style = MaterialTheme.typography.bodySmall)
- }
-}
-
-/**
- * Served, declared and cut, on one line, plus a key for the two colours on the track.
- *
- * The check Peter asked for: the gap between the first two is the advertising in the file, and
- * the third is what the detector took out, so a third that is far from that gap is the detector
- * being wrong. When there is no cut to report, [EpisodeSkips.status] says why -- "not checked
- * yet" is a different answer from "none found" and must stay one. Intro and outro are not here:
- * the server does not publish those offsets yet, and when it does they are marks like any other.
- */
-@Composable
-private fun SkipReadout(skips: EpisodeSkips, breaks: List<SkipSummary.BreakMarker>) {
- Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
- skips.numbers?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
- skips.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
- if (breaks.any { SkipSummary.isAdvertising(it.kind) }) {
- LegendEntry(MaterialTheme.colorScheme.tertiary, SkipSummary.LEGEND_ADVERTISING)
- }
- if (breaks.any { !SkipSummary.isAdvertising(it.kind) }) {
- LegendEntry(MaterialTheme.colorScheme.secondary, SkipSummary.LEGEND_OWN_AUDIO)
- }
- }
-}
-
-@Composable
-private fun LegendEntry(colour: Color, label: String) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- Box(Modifier.size(LEGEND_SWATCH).background(colour))
- Text(label, style = MaterialTheme.typography.bodySmall)
- }
-}
-
-/**
* 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
@@ -424,19 +313,3 @@ private fun RuleAttribution(episode: EpisodeEntity, ruleName: String?) {
val label = if (ruleName != null) "Handled by \"$ruleName\" — $reason" else "Handled by a rule — $reason"
Text(label, style = MaterialTheme.typography.bodySmall)
}
-
-/** Tall enough to tap; the band drawn inside it is what reads as an annotation on the track. */
-private val BREAK_STRIP_HEIGHT = 28.dp
-
-private const val BREAK_BAND_PX = 20f
-
-/** How far from a band a tap still counts as hitting it. */
-private const val TAP_SLOP_PX = 24f
-
-private val LEGEND_SWATCH = 10.dp
-
-/** Material's slider thumb radius, which the track is inset by at both ends. */
-private const val THUMB_RADIUS_PX = 20f
-
-/** A break shorter than this would otherwise draw as nothing at all. */
-private const val MIN_BREAK_MARK_PX = 2f
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt
new file mode 100644
index 0000000..87ccfac
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt
@@ -0,0 +1,173 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.dp
+import org.terst.listen.fingerprint.BreakTrack
+import org.terst.listen.fingerprint.EpisodeSkips
+import org.terst.listen.fingerprint.SkipSummary
+
+/**
+ * This episode's position, with the detected ad breaks marked on it.
+ *
+ * It has to work for an episode that is merely open -- where a drag sets where Play will begin
+ * rather than seeking anything -- as well as the one playing. The drag rule is the same, though: a
+ * finger should not fight the live position tick, so the local value only tracks the drag and
+ * the seek fires once on release.
+ */
+@Composable
+fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) {
+ val duration = playback.durationMs?.takeIf { it > 0 } ?: 0L
+ var dragValue by remember { mutableFloatStateOf(-1f) }
+ val shown = if (dragValue >= 0f) dragValue else playback.positionMs.toFloat()
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Box {
+ Slider(
+ value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)),
+ onValueChange = { dragValue = it },
+ onValueChangeFinished = {
+ actions.seekTo(dragValue.toLong())
+ dragValue = -1f
+ },
+ valueRange = 0f..duration.toFloat().coerceAtLeast(1f),
+ enabled = duration > 0,
+ )
+ }
+ BreakMarks(playback.breaks, duration) { actions.seekTo(it) }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall)
+ Text(formatClock(duration), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+}
+
+/**
+ * The detected breaks as bands on a strip under the slider, advertising in one colour and the
+ * show's own audio (intro, cue, outro) in another, decided by [SkipSummary.isAdvertising] rather
+ * than by comparing against [SkipSummary.describeKind]'s display string -- a renamed label must
+ * not silently invert which colour means advertising. A kind this version does not recognise
+ * draws as advertising too, matching what playback does with it.
+ *
+ * Tapping a band names it and seeks to its start. The strip is its own, taller target rather than
+ * a hairline behind the slider, because a break is a few pixels wide and a finger is not; what a
+ * tap hits is decided by [BreakTrack.markAt], so the composable only converts pixels.
+ */
+@Composable
+private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, onSeek: (Long) -> Unit) {
+ if (breaks.isEmpty()) return
+ var picked by remember(breaks) { mutableStateOf<SkipSummary.BreakMarker?>(null) }
+ val adColour = MaterialTheme.colorScheme.tertiary
+ val furnitureColour = MaterialTheme.colorScheme.secondary
+ val trackColour = MaterialTheme.colorScheme.surfaceVariant
+ val outlineColour = MaterialTheme.colorScheme.onSurface
+ Canvas(
+ Modifier
+ .fillMaxWidth()
+ .height(BREAK_STRIP_HEIGHT)
+ .pointerInput(breaks, durationMs) {
+ detectTapGestures { tap ->
+ val width = size.width.toFloat()
+ val usable = (width - THUMB_RADIUS_PX * 2).coerceAtLeast(1f)
+ val hit = BreakTrack.markAt(
+ breaks,
+ BreakTrack.trackFraction(tap.x, width, THUMB_RADIUS_PX),
+ slop = TAP_SLOP_PX / usable,
+ )
+ picked = hit
+ hit?.let { onSeek(BreakTrack.markStartMs(it, durationMs)) }
+ }
+ },
+ ) {
+ // Inset by the thumb's radius at each end: the slider's track is laid out inside it, so
+ // marks drawn edge to edge would sit a thumb-width off the positions they describe.
+ val inset = THUMB_RADIUS_PX
+ val usable = (size.width - inset * 2).coerceAtLeast(0f)
+ val bandTop = (size.height - BREAK_BAND_PX) / 2
+ drawRect(trackColour, Offset(inset, bandTop), Size(usable, BREAK_BAND_PX))
+ breaks.forEach { mark ->
+ val left = inset + usable * mark.start
+ val right = inset + usable * mark.end
+ val topLeft = Offset(left, bandTop)
+ val bandSize = Size((right - left).coerceAtLeast(MIN_BREAK_MARK_PX), BREAK_BAND_PX)
+ drawRect(if (SkipSummary.isAdvertising(mark.kind)) adColour else furnitureColour, topLeft, bandSize)
+ if (mark == picked) drawRect(outlineColour, topLeft, bandSize, style = Stroke(width = 2f))
+ }
+ }
+ picked?.let {
+ Text(BreakTrack.describeMark(it, durationMs), style = MaterialTheme.typography.bodySmall)
+ }
+}
+
+/**
+ * Served, declared and cut, on one line, plus a key for the two colours on the track.
+ *
+ * The check Peter asked for: the gap between the first two is the advertising in the file, and
+ * the third is what the detector took out, so a third that is far from that gap is the detector
+ * being wrong. When there is no cut to report, [EpisodeSkips.status] says why -- "not checked
+ * yet" is a different answer from "none found" and must stay one. Intro and outro are not here:
+ * the server does not publish those offsets yet, and when it does they are marks like any other.
+ */
+@Composable
+fun SkipReadout(skips: EpisodeSkips, breaks: List<SkipSummary.BreakMarker>) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ skips.numbers?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
+ skips.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
+ if (breaks.any { SkipSummary.isAdvertising(it.kind) }) {
+ LegendEntry(MaterialTheme.colorScheme.tertiary, SkipSummary.LEGEND_ADVERTISING)
+ }
+ if (breaks.any { !SkipSummary.isAdvertising(it.kind) }) {
+ LegendEntry(MaterialTheme.colorScheme.secondary, SkipSummary.LEGEND_OWN_AUDIO)
+ }
+ }
+}
+
+@Composable
+private fun LegendEntry(colour: Color, label: String) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ Box(Modifier.size(LEGEND_SWATCH).background(colour))
+ Text(label, style = MaterialTheme.typography.bodySmall)
+ }
+}
+
+/** Tall enough to tap; the band drawn inside it is what reads as an annotation on the track. */
+private val BREAK_STRIP_HEIGHT = 28.dp
+
+private const val BREAK_BAND_PX = 20f
+
+/** How far from a band a tap still counts as hitting it. */
+private const val TAP_SLOP_PX = 24f
+
+private val LEGEND_SWATCH = 10.dp
+
+/** Material's slider thumb radius, which the track is inset by at both ends. */
+private const val THUMB_RADIUS_PX = 20f
+
+/** A break shorter than this would otherwise draw as nothing at all. */
+private const val MIN_BREAK_MARK_PX = 2f
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt
new file mode 100644
index 0000000..3a56c9d
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt
@@ -0,0 +1,92 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.FastForward
+import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * The one transport: back, the single primary control, forward, and the speed.
+ *
+ * The primary button's wording says what tapping it will do (Play, Resume, Pause) and it is the
+ * only play control on the screen. Speed sits behind a tap showing the current value, so a row
+ * of four chips no longer takes a full line.
+ */
+@Composable
+fun EpisodeTransport(playback: EpisodePlayback, speed: Float, actions: EpisodeDetailActions) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = { actions.skip(-Transport.BACK_MS) }) {
+ Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
+ }
+ Button(onClick = actions.togglePlayPause) {
+ Icon(
+ if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(Transport.primaryLabel(playback.isPlaying, playback.positionMs), Modifier.padding(start = 6.dp))
+ }
+ IconButton(onClick = { actions.skip(Transport.FORWARD_MS) }) {
+ Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds")
+ }
+ SpeedChip(speed, actions.setSpeed)
+ }
+}
+
+private val SPEED_PADDING = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
+
+@Composable
+private fun SpeedChip(current: Float, onSelect: (Float) -> Unit) {
+ var open by remember { mutableStateOf(false) }
+ val selected = SpeedChoices.selected(current)
+ Box {
+ OutlinedButton(onClick = { open = true }, contentPadding = SPEED_PADDING) {
+ Text(speedLabel(current))
+ }
+ DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
+ SpeedChoices.OFFERED.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(speedLabel(option), style = MaterialTheme.typography.bodyLarge) },
+ leadingIcon = {
+ if (option == selected) Icon(Icons.Default.Check, contentDescription = "Current speed")
+ else Box(Modifier.width(24.dp))
+ },
+ onClick = {
+ open = false
+ onSelect(option)
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryNav.kt b/app/src/main/java/org/terst/listen/ui/LibraryNav.kt
index d7a636f..15ec95b 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryNav.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryNav.kt
@@ -3,7 +3,7 @@ package org.terst.listen.ui
/** Where the library screen's actions navigate to, bundled so the signature stays readable. */
data class LibraryNav(
val openSubscription: (String) -> Unit,
- val openPlayer: () -> Unit,
+ val openEpisode: (String) -> Unit,
val openQueue: () -> Unit,
val openSettings: () -> Unit,
)
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
index a3a1149..1deb2e9 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -99,7 +99,7 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav)
togglePlayPause = player::togglePlayPause,
skipBack = player::skipBack,
skipForward = player::skipForward,
- openPlayer = nav.openPlayer,
+ openEpisode = { nowPlaying.episodeId?.let(nav.openEpisode) },
openQueue = nav.openQueue,
),
)
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 2558937..d89cde3 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -91,7 +91,7 @@ private fun ListenApp(
) {
val nav = rememberNavController()
- ShowPlayerWhenShared(player, nav)
+ ShowEpisodeWhenShared(player, nav)
NavHost(navController = nav, startDestination = "library") {
composable("library") {
@@ -100,15 +100,12 @@ private fun ListenApp(
player = player,
nav = LibraryNav(
openSubscription = { nav.navigate("subscription/$it") },
- openPlayer = { nav.navigate("player") },
+ openEpisode = { nav.navigate("episode/$it") },
openQueue = { nav.navigate("queue") },
openSettings = { nav.navigate("settings") },
),
)
}
- composable("player") {
- PlayerScreen(player = player, onBack = { nav.popBackStack() })
- }
episodeRoute(nav, viewModel, player)
settingsRoute(nav, opmlViewModel, player)
rulesRoutes(nav, rulesViewModel)
@@ -118,7 +115,6 @@ private fun ListenApp(
player = player,
nav = QueueNav(
openEpisode = { nav.navigate("episode/$it") },
- openPlayer = { nav.navigate("player") },
openQueue = {},
back = { nav.popBackStack() },
),
@@ -136,8 +132,7 @@ private fun ListenApp(
subscriptionId = id,
nav = SubscriptionNav(
openEpisode = { nav.navigate("episode/$it") },
- openPlayer = { nav.navigate("player") },
- openQueue = { nav.navigate("queue") },
+ openQueue = { nav.navigate("queue") },
back = { nav.popBackStack() },
),
)
@@ -147,19 +142,19 @@ private fun ListenApp(
}
/**
- * Opens the player when playback was started from a share.
+ * Opens the episode when playback was started from a share.
*
* The share activity has no UI and no navigation controller, and the app may not have been
* running at all when the link arrived, so it cannot navigate. It starts playback and this
* reacts once there is something to navigate.
*/
@Composable
-private fun ShowPlayerWhenShared(player: PlayerViewModel, nav: androidx.navigation.NavHostController) {
- val openPlayer by player.openPlayer.collectAsState()
- LaunchedEffect(openPlayer) {
- if (!openPlayer) return@LaunchedEffect
- player.openPlayerHandled()
- nav.navigate("player")
+private fun ShowEpisodeWhenShared(player: PlayerViewModel, nav: androidx.navigation.NavHostController) {
+ val episodeId by player.openEpisode.collectAsState()
+ LaunchedEffect(episodeId) {
+ val id = episodeId ?: return@LaunchedEffect
+ player.openEpisodeHandled()
+ nav.navigate("episode/$id") { launchSingleTop = true }
}
}
@@ -185,8 +180,7 @@ private fun androidx.navigation.NavGraphBuilder.episodeRoute(
episodeId = id,
nav = SubscriptionNav(
openEpisode = { nav.navigate("episode/$it") },
- openPlayer = { nav.navigate("player") },
- openQueue = { nav.navigate("queue") },
+ openQueue = { nav.navigate("queue") },
back = { nav.popBackStack() },
),
)
diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt
index f3deb3f..bc6cba0 100644
--- a/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt
@@ -5,6 +5,6 @@ data class NowPlayingActions(
val togglePlayPause: () -> Unit,
val skipBack: () -> Unit,
val skipForward: () -> Unit,
- val openPlayer: () -> Unit,
+ val openEpisode: () -> Unit,
val openQueue: () -> Unit,
)
diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
index 450d70a..482fa86 100644
--- a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
@@ -63,10 +63,10 @@ fun NowPlayingBar(state: NowPlaying, actions: NowPlayingActions) {
.size(56.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
- .clickable(onClick = actions.openPlayer),
+ .clickable(onClick = actions.openEpisode),
)
- NowPlayingText(state, Modifier.weight(1f), actions.openPlayer)
+ NowPlayingText(state, Modifier.weight(1f), actions.openEpisode)
IconButton(onClick = actions.skipBack) {
Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
index d9b8652..76679dc 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
@@ -19,13 +19,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
- * The now-playing bar as a screen's bottom bar.
+ * The now-playing bar as a screen's bottom bar. [hideBarFor] is the episode whose own screen this
+ * is; the bar is left off there because that screen is the player.
*
* One place rather than repeated per screen: the bar follows you around the app, and three
* copies of the same wiring is three chances for them to drift.
*/
@Composable
-fun PlayerBar(player: PlayerViewModel, onOpenPlayer: () -> Unit, onOpenQueue: () -> Unit) {
+fun PlayerBar(
+ player: PlayerViewModel,
+ onOpenEpisode: (String) -> Unit,
+ onOpenQueue: () -> Unit,
+ hideBarFor: String? = null,
+) {
val nowPlaying by player.nowPlaying.collectAsState()
val preparing by player.preparing.collectAsState()
val banner by player.banner.collectAsState()
@@ -44,14 +50,16 @@ fun PlayerBar(player: PlayerViewModel, onOpenPlayer: () -> Unit, onOpenQueue: ()
val fetching = preparing
if (fetching != null) {
PreparingBar(fetching)
- } else {
+ } else if (hideBarFor == null || nowPlaying.episodeId != hideBarFor) {
+ // Not on the playing episode's own screen, which carries the same controls.
NowPlayingBar(
state = nowPlaying,
actions = NowPlayingActions(
togglePlayPause = player::togglePlayPause,
skipBack = player::skipBack,
skipForward = player::skipForward,
- openPlayer = onOpenPlayer,
+ // The episode screen is the player: the bar opens whatever it is showing.
+ openEpisode = { nowPlaying.episodeId?.let(onOpenEpisode) },
openQueue = onOpenQueue,
),
)
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
deleted file mode 100644
index b826406..0000000
--- a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
+++ /dev/null
@@ -1,247 +0,0 @@
-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.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.FastForward
-import androidx.compose.material.icons.filled.FastRewind
-import androidx.compose.material.icons.filled.Pause
-import androidx.compose.material.icons.filled.PlayArrow
-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.Slider
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.ui.Alignment
-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.feed.Chapter
-
-/** Offered speeds. 1.0x stays in the list as the way back to normal. */
-private val SPEEDS = listOf(1.0f, 1.1f, 1.25f, 2.0f)
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) {
- val nowPlaying by player.nowPlaying.collectAsState()
- val show by player.currentShow.collectAsState()
- val chapters by player.chapters.collectAsState()
- val banner by player.banner.collectAsState()
- val preparing by player.preparing.collectAsState()
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(show?.title ?: "Now Playing", maxLines = 1, overflow = TextOverflow.Ellipsis) },
- navigationIcon = {
- IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
- }
- },
- )
- },
- ) { padding ->
- Column(
- Modifier.padding(padding).fillMaxSize().padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- AsyncImage(
- model = nowPlaying.artworkUrl,
- contentDescription = null,
- modifier = Modifier
- .fillMaxWidth()
- .aspectRatio(1f)
- .clip(RoundedCornerShape(12.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant),
- )
-
- PlayerMessage(banner, onDismiss = player::dismissBanner)
-
- // Tapping Play navigates straight here, so this is the screen that has to say a
- // fetch is running -- otherwise a yt-dlp extraction reads as "Nothing playing".
- Text(
- preparing?.let { "Fetching audio — $it" } ?: nowPlaying.title ?: "Nothing playing",
- style = MaterialTheme.typography.titleLarge,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
-
- Scrubber(
- positionMs = nowPlaying.positionMs,
- durationMs = nowPlaying.durationMs,
- onSeek = player::seekTo,
- )
-
- TransportRow(isPlaying = nowPlaying.isPlaying, player = player)
-
- // Keyed on whole seconds: the link only carries seconds, so rebuilding it on
- // every position tick would be churn for an identical string.
- val watchLink = remember(nowPlaying.positionMs / MS_PER_SECOND) { player.watchLink() }
- WatchButton(watchLink, "Watch from here")
-
- if (show != null) {
- SpeedRow(current = show?.playbackSpeed ?: nowPlaying.speed, onSelect = player::setShowSpeed)
- }
-
- ChapterList(
- chapters = chapters,
- positionMs = nowPlaying.positionMs,
- onSeek = player::seekTo,
- )
- }
- }
-}
-
-private const val MS_PER_SECOND = 1000L
-
-/**
- * The episode's chapters, if it has any.
- *
- * Renders nothing at all when it does not, which is most shows -- an empty "Chapters" heading on
- * every episode would be a permanent reminder of a feature that rarely applies.
- */
-@Composable
-private fun ColumnScope.ChapterList(chapters: List<Chapter>, positionMs: Long, onSeek: (Long) -> Unit) {
- if (chapters.isEmpty()) return
-
- val currentIndex = chapters.indexOfLast { it.startMs <= positionMs }
-
- Column(Modifier.fillMaxWidth().weight(1f, fill = false).verticalScroll(rememberScrollState())) {
- Text("Chapters", style = MaterialTheme.typography.titleSmall)
- chapters.forEachIndexed { index, chapter ->
- Row(
- Modifier.fillMaxWidth().clickable { onSeek(chapter.startMs) }.padding(vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- formatClock(chapter.startMs),
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(end = 12.dp),
- )
- Text(
- chapter.title ?: "Chapter ${index + 1}",
- style = if (index == currentIndex) {
- MaterialTheme.typography.bodyMedium
- } else {
- MaterialTheme.typography.bodySmall
- },
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- // Marked so a skipped section is explained rather than just vanishing.
- if (!chapter.inToc) {
- Text("break", style = MaterialTheme.typography.bodySmall)
- }
- }
- }
- }
-}
-
-@Composable
-private fun TransportRow(isPlaying: Boolean, player: PlayerViewModel) {
- Row(
- Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(onClick = player::skipBack) {
- Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
- }
- IconButton(onClick = player::togglePlayPause) {
- Icon(
- if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
- contentDescription = if (isPlaying) "Pause" else "Play",
- modifier = Modifier.padding(12.dp),
- )
- }
- IconButton(onClick = player::skipForward) {
- Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds")
- }
- }
-}
-
-/**
- * A dragging finger shouldn't fight the live position tick, so the local value only tracks the
- * drag while one is in progress; the seek itself only fires once the drag ends.
- */
-@Composable
-private fun Scrubber(positionMs: Long, durationMs: Long?, onSeek: (Long) -> Unit) {
- val duration = durationMs?.takeIf { it > 0 } ?: 0L
- var dragValue by remember { mutableFloatStateOf(-1f) }
- val shown = if (dragValue >= 0f) dragValue else positionMs.toFloat()
-
- Column(Modifier.fillMaxWidth()) {
- Slider(
- value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)),
- onValueChange = { dragValue = it },
- onValueChangeFinished = {
- onSeek(dragValue.toLong())
- dragValue = -1f
- },
- valueRange = 0f..duration.toFloat().coerceAtLeast(1f),
- enabled = duration > 0,
- )
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall)
- Text(formatClock(duration), style = MaterialTheme.typography.bodySmall)
- }
- }
-}
-
-@Composable
-private fun SpeedRow(current: Float, onSelect: (Float) -> Unit) {
- // Wraps rather than one fixed row: five chips at these label widths overflowed the screen.
- // Compact labels too -- "1.25x" reads the same as "1.25" with a multiplication sign and
- // buys back the width that caused the overflow.
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- SPEEDS.forEach { speed ->
- val label = speedLabel(speed)
- if (current == speed) {
- Button(onClick = { onSelect(speed) }, contentPadding = SPEED_PADDING) { Text(label) }
- } else {
- OutlinedButton(onClick = { onSelect(speed) }, contentPadding = SPEED_PADDING) { Text(label) }
- }
- }
- }
-}
-
-private val SPEED_PADDING = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 6.dp)
-
-/** Drops the trailing zero so 1.0x and 1.5x are as short as they can be. */
-internal fun speedLabel(speed: Float): String {
- val text = if (speed % 1f == 0f) speed.toInt().toString() else speed.toString().trimEnd('0').trimEnd('.')
- return "${text}\u00d7"
-}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
index 81d2ecc..df9bcc1 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -7,15 +7,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
-import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.feed.Chapter
-import org.terst.listen.feed.WatchLink
import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlaybackSpeed
import org.terst.listen.playback.PlayerConnection
import org.terst.listen.playback.StartOutcome
import org.terst.listen.playback.BedtimeAction
@@ -50,22 +47,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
private val _preparing = MutableStateFlow<String?>(null)
val preparing: StateFlow<String?> = _preparing.asStateFlow()
- /**
- * The playing episode, so the watch link can be built without suspending.
- *
- * Declared here rather than beside loadContext: the collector in init emits the current
- * value synchronously, so a property declared after init is still null when it first runs.
- */
- private var currentEpisode: EpisodeEntity? = null
-
- /** Chapters for whatever is playing, empty when the show publishes none. */
- private val _chapters = MutableStateFlow<List<Chapter>>(emptyList())
- val chapters: StateFlow<List<Chapter>> = _chapters.asStateFlow()
-
- /** The show behind whatever is currently playing, for the player screen's speed control. */
- private val _currentShow = MutableStateFlow<SubscriptionEntity?>(null)
- val currentShow: StateFlow<SubscriptionEntity?> = _currentShow.asStateFlow()
-
init {
// Position advances with no callback of its own, so the bar is driven by a tick.
viewModelScope.launch {
@@ -74,50 +55,39 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
connection.publish()
}
}
-
- // Follow whatever is playing rather than only what this screen started. The service
- // moves the queue on by itself, so the show and chapters shown here have to come from
- // the player, not from the last call to play().
- viewModelScope.launch {
- connection.nowPlaying
- .map { it.episodeId }
- .distinctUntilChanged()
- .collect { episodeId -> loadContext(episodeId) }
- }
}
- private suspend fun loadContext(episodeId: String?) {
- val episode = episodeId?.let { graph.episodes.getById(it) }
- currentEpisode = episode
- if (episode == null) {
- _currentShow.value = null
- _chapters.value = emptyList()
- return
- }
- _currentShow.value = graph.subscriptions.getById(episode.subscriptionId)
- // Cached on the episode after the first fetch, so this is a database read in the common
- // case even though the service may have fetched it already.
- _chapters.value = graph.chapters.chapters(episode)
- }
+ /**
+ * What an episode plays at: a rule's choice, else the show's, else normal.
+ *
+ * Whatever is stored, as stored -- 1.5 and 1.75 survive from before the speed list was
+ * trimmed, and nothing here rounds them onto an offered value.
+ */
+ suspend fun speedFor(episode: EpisodeEntity): Float =
+ PlaybackSpeed.forEpisode(episode.speedOverride, graph.subscriptions.getById(episode.subscriptionId)?.playbackSpeed)
+
+ /** The episode's chapters, empty when its show publishes none. Cached after the first fetch. */
+ suspend fun chaptersFor(episode: EpisodeEntity): List<Chapter> = graph.chapters.chapters(episode)
override fun onCleared() {
connection.release()
super.onCleared()
}
- private val _openPlayer = MutableStateFlow(false)
+ private val _openEpisode = MutableStateFlow<String?>(null)
/**
- * Set when playback was started from outside the UI, so the app can show the player.
+ * The episode to show because playback was started from outside the UI.
*
* A share arrives at an activity that may not be running; there is no screen to navigate
* from. This lets the nav graph react once the UI does exist, rather than the share
- * activity trying to drive navigation it knows nothing about.
+ * activity trying to drive navigation it knows nothing about. The episode screen is the
+ * player, so this names the episode to open.
*/
- val openPlayer: StateFlow<Boolean> = _openPlayer.asStateFlow()
+ val openEpisode: StateFlow<String?> = _openEpisode.asStateFlow()
- fun openPlayerHandled() {
- _openPlayer.value = false
+ fun openEpisodeHandled() {
+ _openEpisode.value = null
}
/**
@@ -130,7 +100,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
fun playShared(episodeId: String) {
viewModelScope.launch {
val episode = graph.episodes.getById(episodeId) ?: return@launch
- _openPlayer.value = true
+ _openEpisode.value = episodeId
play(episode)
}
}
@@ -186,39 +156,25 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
}
}
- /**
- * The same moment, on the site the video came from.
- *
- * Null for an ordinary podcast, which has no page to hand off to. The position comes from
- * the player rather than the stored one, so it is where the listener actually is.
- */
- fun watchLink(): String? {
- val playing = connection.nowPlaying.value
- val episode = currentEpisode?.takeIf { it.id == playing.episodeId } ?: return null
- return WatchLink.at(episode.mediaUrl, playing.positionMs)
- }
-
fun togglePlayPause() = connection.togglePlayPause()
- fun skipBack() = connection.seekBy(-SKIP_BACK_MS)
+ fun skipBack() = connection.seekBy(-Transport.BACK_MS)
- fun skipForward() = connection.seekBy(SKIP_FORWARD_MS)
+ fun skipForward() = connection.seekBy(Transport.FORWARD_MS)
fun setSpeed(speed: Float) = connection.setSpeed(speed)
- /** Player screen's scrubber, committed once the drag ends rather than on every move. */
+ /** The scrubber, committed once the drag ends rather than on every move. */
fun seekTo(positionMs: Long) = connection.seekTo(positionMs)
/**
- * Sets the speed for whatever show is currently playing, persisted so it survives to the
- * next episode from that show. This is what makes [SubscriptionEntity.playbackSpeed]
- * reachable -- until the player screen, only the live, unpersisted [setSpeed] existed.
+ * Sets the speed for the show behind [episode], persisted so it survives to the next episode
+ * from that show, and applies it at once when that episode is the one playing. This is what
+ * makes [org.terst.listen.data.SubscriptionEntity.playbackSpeed] reachable from the UI.
*/
- fun setShowSpeed(speed: Float) {
- val show = _currentShow.value ?: return
- viewModelScope.launch { graph.subscriptions.updateSpeed(show.id, speed) }
- _currentShow.value = show.copy(playbackSpeed = speed)
- connection.setSpeed(speed)
+ fun setSpeedFor(episode: EpisodeEntity, speed: Float) {
+ viewModelScope.launch { graph.subscriptions.updateSpeed(episode.subscriptionId, speed) }
+ if (connection.nowPlaying.value.episodeId == episode.id) connection.setSpeed(speed)
}
// --- sleep timer ---------------------------------------------------------------------
@@ -289,12 +245,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
private companion object {
const val PROGRESS_TICK_MS = 1_000L
- // Asymmetric on purpose: back is for catching a missed sentence, forward for skipping
- // an ad break.
- // Identical to the service's seek increments, so the in-app bar and the lock
- // screen never disagree about what a skip does.
- const val SKIP_BACK_MS = 15_000L
- const val SKIP_FORWARD_MS = 25_000L
const val MS_PER_MINUTE = 60_000L
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
index c53fd5b..119f6ee 100644
--- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
@@ -75,7 +75,7 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, nav: QueueNav) {
},
)
},
- bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) },
+ bottomBar = { PlayerBar(player, nav.openEpisode, nav.openQueue) },
) { padding ->
LazyColumn(state = lazyListState, modifier = Modifier.padding(padding).fillMaxSize()) {
items(localOrder, key = { it.id }) { episode ->
@@ -140,7 +140,6 @@ private fun ReorderableCollectionItemScope.QueueRow(
/** Where the queue screen navigates to, bundled like [LibraryNav] and [SubscriptionNav]. */
data class QueueNav(
val openEpisode: (String) -> Unit,
- val openPlayer: () -> Unit,
val openQueue: () -> Unit,
val back: () -> Unit,
)
diff --git a/app/src/main/java/org/terst/listen/ui/SpeedChoices.kt b/app/src/main/java/org/terst/listen/ui/SpeedChoices.kt
new file mode 100644
index 0000000..0c49c82
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/SpeedChoices.kt
@@ -0,0 +1,23 @@
+package org.terst.listen.ui
+
+/**
+ * The speeds the episode screen offers.
+ *
+ * Offering four does not limit what can play: a show or rule can already be storing 1.5 or 1.75
+ * from before the list was trimmed, and [org.terst.listen.playback.PlaybackSpeed] resolves
+ * whatever is stored without consulting this list. This is presentation only.
+ */
+object SpeedChoices {
+
+ /** 1.0x stays in the list as the way back to normal. */
+ val OFFERED: List<Float> = listOf(1.0f, 1.1f, 1.25f, 2.0f)
+
+ /** The offered speed that [current] is, or null when it is stored but not offered (1.5, 1.75). */
+ fun selected(current: Float): Float? = OFFERED.firstOrNull { it == current }
+}
+
+/** Drops the trailing zero so 1.0x and 1.5x are as short as they can be. */
+internal fun speedLabel(speed: Float): String {
+ val text = if (speed % 1f == 0f) speed.toInt().toString() else speed.toString().trimEnd('0').trimEnd('.')
+ return "${text}×"
+}
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 c93f353..7712bf1 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -96,7 +96,7 @@ fun SubscriptionScreen(
)
},
snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
- bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) },
+ bottomBar = { PlayerBar(player, nav.openEpisode, nav.openQueue) },
) { padding ->
// Confirmed, because unsubscribing cascades: the episodes go, and with them every
// playback position and played mark for that show. None of it is recoverable.
@@ -263,7 +263,6 @@ data class SubscriptionNav(
// 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/main/java/org/terst/listen/ui/Transport.kt b/app/src/main/java/org/terst/listen/ui/Transport.kt
new file mode 100644
index 0000000..80c1018
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/Transport.kt
@@ -0,0 +1,21 @@
+package org.terst.listen.ui
+
+/** The episode screen's transport arithmetic, kept out of the composable so it can be tested. */
+object Transport {
+
+ const val BACK_MS = 15_000L
+ const val FORWARD_MS = 25_000L
+
+ /** Where a skip lands, kept inside the episode. */
+ fun stepped(positionMs: Long, deltaMs: Long, durationMs: Long?): Long {
+ val target = (positionMs + deltaMs).coerceAtLeast(0L)
+ return if (durationMs != null && durationMs > 0) target.coerceAtMost(durationMs) else target
+ }
+
+ /** What the one primary control says. */
+ fun primaryLabel(isPlaying: Boolean, positionMs: Long): String = when {
+ isPlaying -> "Pause"
+ positionMs > 0 -> "Resume"
+ else -> "Play"
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/WatchButton.kt b/app/src/main/java/org/terst/listen/ui/WatchButton.kt
deleted file mode 100644
index 710207e..0000000
--- a/app/src/main/java/org/terst/listen/ui/WatchButton.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package org.terst.listen.ui
-
-import android.content.Intent
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-import androidx.core.net.toUri
-
-/**
- * Hands off to the site a video came from, at the point reached here.
- *
- * Shared by the player and the episode screen. The episode screen matters as much as the
- * player: an episode that cannot be fetched -- YouTube declining to serve it, say -- never
- * starts playing, and that is exactly when watching it elsewhere is the point.
- *
- * Renders nothing when there is no link, which is every ordinary podcast.
- */
-@Composable
-fun WatchButton(link: String?, label: String) {
- if (link == null) return
- val context = LocalContext.current
-
- OutlinedButton(onClick = {
- // Not restricted to a package: whichever app claims the link handles it, falling back
- // to a browser.
- runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) }
- }) { Text(label) }
-}
diff --git a/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt b/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt
new file mode 100644
index 0000000..6e9d9c1
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt
@@ -0,0 +1,58 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DescriptionPreviewTest {
+
+ @Test
+ fun `a short description is shown whole`() {
+ val p = DescriptionPreview.collapse("A short note.")
+ assertEquals("A short note.", p.text)
+ assertFalse(p.truncated)
+ }
+
+ @Test
+ fun `two lines fit`() {
+ val p = DescriptionPreview.collapse("First.\nSecond.")
+ assertEquals("First.\nSecond.", p.text)
+ assertFalse(p.truncated)
+ }
+
+ @Test
+ fun `a third line is cut and the rest is offered`() {
+ val p = DescriptionPreview.collapse("First.\nSecond.\nThird.")
+ assertEquals("First.\nSecond.…", p.text)
+ assertTrue(p.truncated)
+ }
+
+ @Test
+ fun `blank lines do not use up the allowance`() {
+ val p = DescriptionPreview.collapse("First.\n\n\nSecond.")
+ assertEquals("First.\nSecond.", p.text)
+ assertFalse(p.truncated)
+ }
+
+ @Test
+ fun `one long paragraph is cut at a word boundary within the budget`() {
+ val long = "word ".repeat(100).trim()
+ val p = DescriptionPreview.collapse(long)
+ assertTrue(p.truncated)
+ assertTrue(p.text.endsWith("word…"))
+ assertTrue(p.text.length <= DescriptionPreview.MAX_CHARS + 1)
+ }
+
+ @Test
+ fun `an unbroken string is hard cut rather than dropped`() {
+ val p = DescriptionPreview.collapse("x".repeat(500))
+ assertTrue(p.truncated)
+ assertEquals(DescriptionPreview.MAX_CHARS + 1, p.text.length)
+ }
+
+ @Test
+ fun `an empty description previews as nothing`() {
+ assertEquals(DescriptionPreview.Preview("", false), DescriptionPreview.collapse(""))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
new file mode 100644
index 0000000..ca20319
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt
@@ -0,0 +1,80 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.ui.EpisodeMenuAction.CHAPTERS
+import org.terst.listen.ui.EpisodeMenuAction.DOWNLOAD
+import org.terst.listen.ui.EpisodeMenuAction.MARK_OLDER_PLAYED
+import org.terst.listen.ui.EpisodeMenuAction.PUBLISHER_COPY
+import org.terst.listen.ui.EpisodeMenuAction.QUEUE
+import org.terst.listen.ui.EpisodeMenuAction.TOGGLE_PLAYED
+import org.terst.listen.ui.EpisodeMenuAction.WATCH
+
+class EpisodeMenuTest {
+
+ private fun episode(state: DownloadState = DownloadState.NONE, playedAtMs: Long? = null) = EpisodeEntity(
+ id = "e1", subscriptionId = "s1", guid = "g", title = "One", mediaUrl = "https://cdn/1.mp3",
+ mediaType = "audio/mpeg", discoveredAtMs = 0, downloadState = state, playedAtMs = playedAtMs,
+ )
+
+ private fun actions(e: EpisodeEntity, chapters: Boolean = false, link: String? = null) =
+ EpisodeMenu.items(e, chapters, link).map { it.action }
+
+ @Test
+ fun `an ordinary episode offers the everyday actions and nothing conditional`() {
+ assertEquals(listOf(QUEUE, DOWNLOAD, TOGGLE_PLAYED, MARK_OLDER_PLAYED), actions(episode()))
+ }
+
+ @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)
+ assertEquals("Download from publisher instead", items.single { it.action == PUBLISHER_COPY }.label)
+ }
+
+ @Test
+ fun `no other state offers the publisher's copy`() {
+ DownloadState.entries.filter { it != DownloadState.WAITING_FOR_SERVER }.forEach {
+ assertFalse("$it", PUBLISHER_COPY in actions(episode(it)))
+ }
+ }
+
+ @Test
+ fun `a downloaded episode does not offer the publisher's copy`() {
+ assertFalse(PUBLISHER_COPY in actions(episode(DownloadState.DOWNLOADED)))
+ }
+
+ @Test
+ fun `chapters appear only when the episode has some`() {
+ assertFalse(CHAPTERS in actions(episode(), chapters = false))
+ assertTrue(CHAPTERS in actions(episode(), chapters = true))
+ }
+
+ @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"))
+ }
+
+ @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(
+ "Unplayed",
+ EpisodeMenu.items(episode(playedAtMs = 5L), false, 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 }
+ assertEquals("Queue", labels[QUEUE])
+ assertEquals("Download", labels[DOWNLOAD])
+ assertEquals("Watch", labels[WATCH])
+ assertEquals("Mark this and older as played", labels[MARK_OLDER_PLAYED])
+ assertEquals("Chapters", labels[CHAPTERS])
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt b/app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt
new file mode 100644
index 0000000..fbf0489
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt
@@ -0,0 +1,44 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.playback.PlaybackSpeed
+
+/**
+ * The speed chip offers four speeds, but anything already stored has to keep playing at exactly
+ * what was stored: the list is presentation, not a set of allowed values.
+ */
+class SpeedChoicesTest {
+
+ @Test
+ fun `the offered speeds are exactly these four`() {
+ assertEquals(listOf(1.0f, 1.1f, 1.25f, 2.0f), SpeedChoices.OFFERED)
+ }
+
+ @Test
+ fun `an offered speed is the selected one`() {
+ SpeedChoices.OFFERED.forEach { assertEquals(it, SpeedChoices.selected(it)) }
+ }
+
+ @Test
+ fun `a stored speed that is not offered selects nothing but is not rewritten`() {
+ assertNull(SpeedChoices.selected(1.5f))
+ assertNull(SpeedChoices.selected(1.75f))
+ }
+
+ @Test
+ fun `every stored speed still resolves to itself for the show and for a rule`() {
+ listOf(1.0f, 1.1f, 1.25f, 1.5f, 1.75f, 2.0f).forEach { stored ->
+ assertEquals(stored, PlaybackSpeed.forEpisode(episodeOverride = null, showSpeed = stored))
+ assertEquals(stored, PlaybackSpeed.forEpisode(episodeOverride = stored, showSpeed = 1.0f))
+ }
+ }
+
+ @Test
+ fun `the chip names a stored speed that is not offered`() {
+ assertEquals("1.5×", speedLabel(1.5f))
+ assertEquals("1.75×", speedLabel(1.75f))
+ assertEquals("1.25×", speedLabel(1.25f))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/TransportTest.kt b/app/src/test/java/org/terst/listen/ui/TransportTest.kt
new file mode 100644
index 0000000..a428911
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/TransportTest.kt
@@ -0,0 +1,38 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TransportTest {
+
+ @Test
+ fun `the steps match the lock screen's`() {
+ assertEquals(15_000L, Transport.BACK_MS)
+ assertEquals(25_000L, Transport.FORWARD_MS)
+ }
+
+ @Test
+ fun `a skip moves by the step`() {
+ assertEquals(85_000L, Transport.stepped(100_000, -Transport.BACK_MS, 600_000))
+ assertEquals(125_000L, Transport.stepped(100_000, Transport.FORWARD_MS, 600_000))
+ }
+
+ @Test
+ fun `a skip stays inside the episode`() {
+ assertEquals(0L, Transport.stepped(5_000, -Transport.BACK_MS, 600_000))
+ assertEquals(600_000L, Transport.stepped(590_000, Transport.FORWARD_MS, 600_000))
+ }
+
+ @Test
+ fun `an unknown length only clamps at the start`() {
+ assertEquals(0L, Transport.stepped(5_000, -Transport.BACK_MS, null))
+ assertEquals(30_000L, Transport.stepped(5_000, Transport.FORWARD_MS, null))
+ }
+
+ @Test
+ fun `the primary control says what it will do`() {
+ assertEquals("Pause", Transport.primaryLabel(isPlaying = true, positionMs = 5_000))
+ assertEquals("Resume", Transport.primaryLabel(isPlaying = false, positionMs = 5_000))
+ assertEquals("Play", Transport.primaryLabel(isPlaying = false, positionMs = 0))
+ }
+}