summaryrefslogtreecommitdiff
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
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
-rw-r--r--CLAUDE.md15
-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
26 files changed, 965 insertions, 666 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index d3ecf9c..44189b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -123,7 +123,7 @@ downloaded at once, Facebook videos are supported, the library ranks shows by th
`FeedArchiveWalker` following RFC 5005 `prev-archive` pages to fetch a back catalogue. A drip
release always enqueues and deliberately bypasses the rule engine.
- **Track 3, simple skip — done.** Manual per-show intro/outro offsets through
- `PlaybackProgress`, and Podcasting 2.0 chapters: `ChapterParser`, a chapter list in the player,
+ `PlaybackProgress`, and Podcasting 2.0 chapters: `ChapterParser`, a chapter list in the episode screen's overflow menu,
and opt-in skipping of breaks the publisher marked `toc: false`. Nothing is guessed from
chapter titles.
@@ -412,7 +412,7 @@ by default and correct; it is simply waiting for a publisher who marks breaks.
- **A timestamped link shared in starts playing there.** `WatchLink` parses the `t`, the
repository stores it as the position, and the share activity hands the episode to
- `MainActivity.playIntent` so the app opens on the player already playing -- the point being
+ `MainActivity.playIntent` so the app opens on that episode's screen already playing -- the point being
to move between YouTube and Listen without hunting for your place. Re-sharing a video
already in the library updates its position and unplays it, which is the case that was
broken: the insert ignores a clashing guid, so every share after the first silently did
@@ -467,6 +467,15 @@ by default and correct; it is simply waiting for a publisher who marks breaks.
## Things a future session should know
+- **The episode screen is the player; there is no separate player screen** (merged 2026-10-19).
+ Opening an episode puts you in its transport at the stored position, and pressing play changes
+ nothing about the layout. Playback state still has one owner, `PlayerViewModel`; the screen
+ only reads `nowPlaying` and asks it to start, pause or seek. The now-playing bar opens the
+ playing episode's screen, and steps aside on that screen. Speed is a chip (`SpeedChoices.OFFERED`
+ is 1.0/1.1/1.25/2.0); a show stored at 1.5 or 1.75 keeps playing at it, the chip just names it.
+ Secondary actions and chapters are in the `⋮` menu, whose contents `EpisodeMenu` decides, and
+ the description shows ~two lines (`DescriptionPreview`) with the rest on tap.
+
- **Framework adapters are excluded from coverage, as of 2026-09-26.** The gate had been
overridden eight times, every one a class whose whole body is a call into ExoPlayer, Media3,
Room, WorkManager or Chaquopy. The last few overrides were perverse: pulling decisions *out*
@@ -544,7 +553,7 @@ by default and correct; it is simply waiting for a publisher who marks breaks.
- **A video episode can be handed off to its own site at the current position**, and a link
shared *in* with a timestamp starts there. `WatchLink` does both, and is pure so every URL
shape is pinned -- an unrecognised one fails silently, as a link that starts from the
- beginning. The button lives on the episode screen as well as the player, deliberately: an
+ beginning. The action lives in the episode screen's overflow menu, deliberately: an
episode that cannot be fetched never starts playing, and that is exactly when watching it
elsewhere is the point.
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))
+ }
+}