summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/playback/BrowsedAudio.kt27
-rw-r--r--app/src/main/java/org/terst/listen/playback/BrowsedStart.kt18
-rw-r--r--app/src/main/java/org/terst/listen/playback/LibraryTree.kt10
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt64
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt19
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt20
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt21
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt52
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt16
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt7
-rw-r--r--app/src/test/java/org/terst/listen/OutboundRequestsTest.kt64
-rw-r--r--app/src/test/java/org/terst/listen/playback/BrowsedAudioTest.kt56
-rw-r--r--app/src/test/java/org/terst/listen/playback/BrowsedStartTest.kt42
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt119
15 files changed, 340 insertions, 197 deletions
diff --git a/app/src/main/java/org/terst/listen/playback/BrowsedAudio.kt b/app/src/main/java/org/terst/listen/playback/BrowsedAudio.kt
new file mode 100644
index 0000000..123466f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/BrowsedAudio.kt
@@ -0,0 +1,27 @@
+package org.terst.listen.playback
+
+/**
+ * Which bytes an episode started from outside the app plays.
+ *
+ * Android Auto, a Bluetooth button and system resumption all resolve a browse id straight to a
+ * media item, with no screen to show a label on and no patience for a download. Two earlier
+ * answers were wrong in opposite directions: falling back to the publisher's enclosure played a
+ * differently-ad-inserted copy, so the server's measured breaks cut into the programme; fetching
+ * first put a tens-of-megabyte download inside the future the car is waiting on, so it reported
+ * a failure and played the episode only once the download had finished by itself.
+ *
+ * Streaming was never the problem. Streaming *the publisher's copy* was. The server's copy is the
+ * audio the offsets were measured against, so playing it is as correct as playing the download
+ * and it starts at once.
+ */
+object BrowsedAudio {
+
+ /**
+ * The downloaded file, else the server's own copy, else nothing.
+ *
+ * Nothing is a real answer here. A car that plays silence is a bad moment the listener can
+ * retry; a car that plays the publisher's copy skips into the programme and says nothing.
+ */
+ fun uriFor(localPath: String?, serverAudioUrl: String?): String? =
+ localPath?.takeIf { it.isNotBlank() } ?: serverAudioUrl?.takeIf { it.isNotBlank() }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/BrowsedStart.kt b/app/src/main/java/org/terst/listen/playback/BrowsedStart.kt
index 987fe95..3c3a92c 100644
--- a/app/src/main/java/org/terst/listen/playback/BrowsedStart.kt
+++ b/app/src/main/java/org/terst/listen/playback/BrowsedStart.kt
@@ -42,4 +42,22 @@ object BrowsedStart {
requestedIndex >= count -> count - 1
else -> requestedIndex
}
+
+ /**
+ * The episodes to hand the player, given the one chosen and the queue as it stands.
+ *
+ * Tapping an episode in the car used to set the player's playlist to that item alone. The
+ * car's Up Next then showed one episode rather than the queue, and a single-item playlist
+ * loops rather than moving on -- both of which Peter reported. The browse tree's "Up Next"
+ * node was listing the real queue all along; the *player's* playlist is a different thing
+ * and nothing was filling it in.
+ *
+ * An episode that is not in the queue plays alone. Dragging the queue in behind it would
+ * silently change what plays next, which is not what tapping an episode in a show means.
+ */
+ fun playlist(chosen: String, queue: List<String>): List<String> =
+ if (chosen in queue) queue else listOf(chosen)
+
+ /** Where [chosen] sits in the list [playlist] returned, so the player starts on it. */
+ fun indexOf(chosen: String, playlist: List<String>): Int = playlist.indexOf(chosen).coerceAtLeast(0)
}
diff --git a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
index 3c12f1d..371dd1c 100644
--- a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
+++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
@@ -32,6 +32,16 @@ object LibraryTree {
fun root(): MediaItem = browsable(ROOT, "Listen")
+ /**
+ * A bare reference to an episode, carrying only its browse id.
+ *
+ * For building a playlist out of ids alone -- `onSetMediaItems` hands the queue to
+ * `onAddMediaItems`, which resolves each id into something playable. Nothing here is shown,
+ * so there is no title or artwork to look up.
+ */
+ fun episodeRef(episodeId: String): MediaItem =
+ MediaItem.Builder().setMediaId(EPISODE_PREFIX + episodeId).build()
+
fun topLevel(): List<MediaItem> = listOf(
browsable(QUEUE, "Up Next"),
browsable(SUBSCRIPTIONS, "Podcasts"),
diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
index c5ffe59..7a02ba6 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -21,7 +21,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.guava.future
+import kotlinx.coroutines.launch
import org.terst.listen.data.Graph
+import org.terst.listen.server.ServerAnswer
/**
* What a controller -- the notification, a Bluetooth remote, or a car head unit -- is allowed to
@@ -174,21 +176,36 @@ class ListenSessionCallback(
): ListenableFuture<MutableList<MediaItem>> = scope.future {
mediaItems.mapNotNull { item ->
val episodeId = LibraryTree.episodeId(item.mediaId) ?: return@mapNotNull item
- // Through the advancer, which fetches the audio if there is none. This used to read
- // the row and build an item straight off it, so an undownloaded episode was handed
- // to the player as its publisher's URL and streamed -- in the car, where the
- // resulting misplaced skips are least likely to be noticed and most annoying.
- val ready = graph.advancer.startFetchingIfNeeded(episodeId) as? StartOutcome.Ready
- if (ready == null) {
+ val episode = graph.episodes.getById(episodeId) ?: return@mapNotNull null
+ val show = graph.subscriptions.getById(episode.subscriptionId)
+
+ // Never a download. This briefly fetched the audio here, which put tens of megabytes
+ // inside the future Android Auto is waiting on: the car reported a failure and the
+ // episode played later only because the download had finished by itself. The server
+ // is asked instead -- one small request -- and its copy is the audio the offsets
+ // were measured on, so streaming it is as correct as playing the file.
+ val serverUrl = if (!episode.localPath.isNullOrBlank()) {
+ null
+ } else {
+ (graph.serverSkips.resolve(episode) as? ServerAnswer.Found)
+ ?.episode?.takeIf { it.ready }?.audioUrl
+ }
+
+ val uri = BrowsedAudio.uriFor(episode.localPath, serverUrl)
+ if (uri == null) {
// Nothing playable, and deliberately not the publisher's copy. Logged because
- // there is no banner in a car.
- Log.w("ListenPlayback", "cannot start $episodeId from outside the app")
+ // there is no banner in a car. The download is started so the next try works.
+ Log.w("ListenPlayback", "nothing to play for $episodeId from outside the app")
+ // Fetch it for next time, on its own coroutine. Deliberately not awaited: doing
+ // that here is what made the car time out in the first place.
+ show?.kind?.let { kind -> scope.launch { graph.advancer.fetchAndStart(episode, kind) } }
return@mapNotNull null
}
- ready.next.episode.toMediaItem(
- showTitle = ready.next.show?.title,
- author = ready.next.show?.author,
- showImageUrl = ready.next.show?.imageUrl,
+ episode.toMediaItem(
+ showTitle = show?.title,
+ author = show?.author,
+ showImageUrl = show?.imageUrl,
+ uri = uri,
)
}.toMutableList()
}
@@ -218,16 +235,29 @@ class ListenSessionCallback(
// the resolved item gives null, and null resumes at zero, which is the bug this fixes
// wearing a different hat.
val index = BrowsedStart.index(startIndex, mediaItems.size)
- val resumeMs = mediaItems.getOrNull(index)
- ?.let { LibraryTree.episodeId(it.mediaId) }
- ?.let { graph.advancer.startFetchingIfNeeded(it) }
+ val chosen = mediaItems.getOrNull(index)?.let { LibraryTree.episodeId(it.mediaId) }
+
+ val resumeMs = chosen
+ ?.let { graph.advancer.start(it) }
?.let { (it as? StartOutcome.Ready)?.next?.startPositionMs }
?: 0L
- val resolved = onAddMediaItems(mediaSession, controller, mediaItems).await()
+ // The player's playlist is the queue, not the one episode tapped. Without this the car's
+ // own Up Next showed a single item and a single-item playlist loops instead of moving on.
+ // An episode browsed from a show is not in the queue and plays alone, because dragging
+ // the queue in behind it would quietly change what plays next.
+ val wanted = if (chosen == null) {
+ mediaItems
+ } else {
+ val queue = graph.queueDao.getEntries().map { it.episodeId }
+ BrowsedStart.playlist(chosen, queue).map { LibraryTree.episodeRef(it) }.toMutableList()
+ }
+ val startOn = chosen?.let { BrowsedStart.indexOf(it, wanted.mapNotNull { m -> LibraryTree.episodeId(m.mediaId) }) } ?: index
+
+ val resolved = onAddMediaItems(mediaSession, controller, wanted).await()
MediaSession.MediaItemsWithStartPosition(
resolved,
- BrowsedStart.index(startIndex, resolved.size),
+ BrowsedStart.index(startOn, resolved.size),
BrowsedStart.positionMs(startPositionMs, resumeMs),
)
}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
index 9189d78..43df43a 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
@@ -190,25 +190,6 @@ class PlaybackAdvancer(
)
}
- /**
- * Begins [episodeId], fetching its audio first if there is none, in one call.
- *
- * For callers with nobody to tell. The in-app paths take [start], show a label while the
- * fetch runs and then resolve again; Android Auto, a Bluetooth button and system resumption
- * all arrive at the session callback with no screen to put a label on, and before this they
- * skipped the question entirely -- a browse id became a media item carrying the publisher's
- * URL, and the car streamed it.
- *
- * A failed fetch stays failed. Falling back to the publisher is the thing being removed:
- * its copy carries different advertising, so the breaks measured on the server's copy land
- * in the wrong place, and silence that can be retried beats skipping into the programme.
- */
- suspend fun startFetchingIfNeeded(episodeId: String): StartOutcome =
- when (val outcome = start(episodeId)) {
- is StartOutcome.NeedsFetch -> fetchAndStart(outcome.episode, outcome.kind)
- else -> outcome
- }
-
/** What it would take to begin [episodeId]. */
suspend fun start(episodeId: String): StartOutcome {
val episode = episodes.getById(episodeId) ?: return StartOutcome.Gone
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
index f7e3235..a2ca31c 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -358,22 +358,26 @@ private class ExoPlayhead(private val player: ExoPlayer) : Playhead {
override val durationMs: Long? get() = player.duration.takeIf { it > 0 }
}
-/** Builds the Media3 item for an episode, preferring a downloaded file over the network. */
+/**
+ * Builds the Media3 item for an episode.
+ *
+ * [uri] defaults to the downloaded file, which is what every in-app path has by the time it gets
+ * here -- PlaybackAdvancer fetches first and reports Gone when it cannot. The car cannot wait for
+ * a download, so it passes the skip server's own audio URL instead; see [BrowsedAudio]. What
+ * neither may pass is the publisher's enclosure, whose advertising is not the advertising the
+ * breaks were measured against.
+ */
fun EpisodeEntity.toMediaItem(
showTitle: String? = null,
author: String? = null,
showImageUrl: String? = null,
+ uri: String = localPath.orEmpty(),
): MediaItem =
MediaItem.Builder()
// The episode id, so whatever comes back from the player can be tied to a row.
.setMediaId(id)
- // The app's own copy, always. There used to be a fallback to the enclosure URL when
- // there was no file, and it was reachable from every play -- so pressing play on an
- // undownloaded episode streamed it from the publisher, whose copy carries different
- // advertising from the one the server measured the skips on. PlaybackAdvancer now
- // fetches first and reports Gone when it cannot, so there is nothing left to fall back
- // for. An empty uri here would mean a caller bypassed it.
- .setUri(localPath.orEmpty())
+ // Our copy or the server's, never the publisher's. See the KDoc above.
+ .setUri(uri)
.setMimeType(mediaType)
.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder()
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
index 45ed36b..bad73db 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -1,7 +1,8 @@
package org.terst.listen.ui
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -14,7 +15,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.ErrorOutline
-import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistPlay
import androidx.compose.material.icons.filled.Schedule
@@ -40,6 +40,7 @@ import org.terst.listen.data.EpisodeEntity
* last one collapsed into a one-letter-per-line column. Every icon still carries a
* contentDescription, so the row reads the same to a screen reader as it did with labels.
*/
+@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artworkUrl: String? = null) {
// The whole row opens the episode, not just the title text. Making only the text tappable
@@ -48,7 +49,10 @@ internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artwork
Row(
Modifier
.fillMaxWidth()
- .clickable(onClick = actions.openDetails)
+ // Tap opens the episode; press and hold plays it. The row used to carry a play
+ // button, and with five icons across a phone the useful ones were the hardest to
+ // hit. Holding is the gesture for "just do it", and it costs no width.
+ .combinedClickable(onClick = actions.openDetails, onLongClick = actions.play)
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)
) {
AsyncImage(
@@ -65,17 +69,12 @@ internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artwork
Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2)
Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
+ // Three, not five. Play moved to a long press on the row and download to the
+ // overflow menu, because the row had five icons across a phone and the two that get
+ // used -- queue it, or get it out of the way -- were the fiddliest to hit.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- ActionIcon(
- icon = Icons.Default.PlayArrow,
- // "Resume" and "Play" look identical as an icon, so the distinction moves
- // into the label a screen reader reads out.
- description = if (episode.positionMs > 0) "Resume" else "Play",
- onClick = actions.play,
- )
ActionIcon(Icons.Default.PlaylistPlay, "Play next", actions.playNext)
ActionIcon(Icons.Default.PlaylistAdd, "Add to queue", actions.enqueue)
- DownloadIcon(episode.downloadState, actions.download)
ActionIcon(
icon = if (episode.playedAtMs == null) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
description = if (episode.playedAtMs == null) "Mark played" else "Mark unplayed",
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 1e8864f..5635f28 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.foundation.clickable
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -69,15 +70,7 @@ fun EpisodeScreen(
val nowPlaying by player.nowPlaying.collectAsState()
val sleep by player.sleepState.collectAsState()
- // What the screen shows, which is the route's episode until the player moves off it. The
- // route argument cannot be the answer on its own: the queue advances without any navigation,
- // and the screen is the player now. See EpisodeFollowing.
- var shownId by remember(episodeId) { mutableStateOf(episodeId) }
- var wasPlaying by remember(episodeId) { mutableStateOf(nowPlaying.episodeId) }
- LaunchedEffect(nowPlaying.episodeId) {
- shownId = EpisodeFollowing.shown(shownId, wasPlaying, nowPlaying.episodeId)
- wasPlaying = nowPlaying.episodeId
- }
+ val shownId = rememberShownEpisode(episodeId, nowPlaying.episodeId)
LaunchedEffect(shownId) {
EpisodeOpening.open(shownId, ScreenSource(vm, player))?.let {
@@ -101,7 +94,12 @@ fun EpisodeScreen(
)
Scaffold(
topBar = {
- EpisodeTopBar(EpisodeTitles.topBar(showTitle), nav.back) {
+ // The show's name is in this bar and did nothing when tapped.
+ EpisodeTopBar(
+ EpisodeTitles.topBar(showTitle),
+ nav.back,
+ current?.subscriptionId?.let { id -> { nav.openSubscription(id) } },
+ ) {
current?.let { shown ->
Overflow(ScreenSource(vm, player), shown, nowPlaying, sleep, hooks)
}
@@ -242,11 +240,41 @@ private fun EpisodeDetail(
}
}
+/**
+ * Which episode the screen is showing: the route's, until the player moves off it.
+ *
+ * The route argument cannot be the answer on its own -- the queue advances with no navigation at
+ * all, and this screen is the player. [EpisodeFollowing] owns the rule and is tested; this is the
+ * remembering.
+ */
+@Composable
+private fun rememberShownEpisode(routeId: String, playingId: String?): String {
+ var shownId by remember(routeId) { mutableStateOf(routeId) }
+ var wasPlaying by remember(routeId) { mutableStateOf(playingId) }
+ LaunchedEffect(playingId) {
+ shownId = EpisodeFollowing.shown(shownId, wasPlaying, playingId)
+ wasPlaying = playingId
+ }
+ return shownId
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun EpisodeTopBar(title: String, onBack: () -> Unit, overflow: @Composable () -> Unit) {
+private fun EpisodeTopBar(
+ title: String,
+ onBack: () -> Unit,
+ onTitleClick: (() -> Unit)?,
+ overflow: @Composable () -> Unit,
+) {
TopAppBar(
- title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ title = {
+ Text(
+ title,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = if (onTitleClick == null) Modifier else Modifier.clickable(onClick = onTitleClick),
+ )
+ },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
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 63aead7..964fcd9 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -135,6 +135,7 @@ private fun ListenApp(
openQueue = { nav.navigate("queue") },
back = { nav.popBackStack() },
openTranscript = { nav.navigate("transcript/$it") },
+ openSubscription = { nav.navigate("subscription/$it") },
),
)
}
@@ -192,6 +193,7 @@ private fun androidx.navigation.NavGraphBuilder.episodeRoute(
openQueue = { nav.navigate("queue") },
back = { nav.popBackStack() },
openTranscript = { nav.navigate("transcript/$it") },
+ openSubscription = { nav.navigate("subscription/$it") },
),
)
}
diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt
index 316b72f..7b793bb 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.PlaylistPlay
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -128,9 +130,17 @@ private fun LatestEpisodeLine(latest: EpisodeEntity, actions: EpisodeActions) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
- ActionIcon(Icons.Default.PlayArrow, "Play ${latest.title}", actions.play)
+ // Play next, queue, mark played -- not play, queue, download. The three that were here
+ // were the three a listener needs least: play is a long press on the line, and download
+ // happens by itself for anything queued. What was missing was the way to say "not now"
+ // without opening the show, which is what sends a library back to being scrollable.
+ ActionIcon(Icons.Default.PlaylistPlay, "Play ${latest.title} next", actions.playNext)
ActionIcon(Icons.Default.PlaylistAdd, "Queue ${latest.title}", actions.enqueue)
- DownloadIcon(latest.downloadState, actions.download)
+ ActionIcon(
+ icon = if (latest.playedAtMs == null) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
+ description = if (latest.playedAtMs == null) "Mark ${latest.title} played" else "Mark ${latest.title} unplayed",
+ onClick = actions.togglePlayed,
+ )
}
}
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 5313e71..32a4c0d 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -254,6 +254,13 @@ data class SubscriptionNav(
val back: () -> Unit,
/** Opens an episode's words, or the recorded reason it has none. */
val openTranscript: (String) -> Unit,
+ /**
+ * Opens the show an episode belongs to.
+ *
+ * The episode screen's top bar carries the show's name and did nothing when tapped, which
+ * is the one place the name is in front of you and the show is a tap away.
+ */
+ val openSubscription: (String) -> Unit,
)
/** The top bar's four callbacks, bundled so the signature stays readable. */
diff --git a/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt b/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt
index 3d59071..7a0f709 100644
--- a/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt
+++ b/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt
@@ -78,57 +78,35 @@ class OutboundRequestsTest {
}
/**
- * The app plays its own copy of an episode, and there is no longer a way not to.
+ * Playback never reaches for the publisher's enclosure.
*
- * `toMediaItem` used to fall back to the enclosure URL when there was no file, and that
- * fallback was reachable from every play: pressing play on an undownloaded episode streamed
- * it from the publisher's CDN. Unasked-for, and wrong -- that copy carries different
- * advertising from the one the server measured the breaks on.
+ * This has been got wrong in both directions. First `toMediaItem` fell back to the enclosure
+ * URL whenever there was no file, so pressing play on an undownloaded episode streamed the
+ * publisher's copy -- which carries different advertising, so the breaks the server measured
+ * cut into the programme. Then the fix made the car *download* before playing, which put tens
+ * of megabytes inside the future Android Auto waits on and broke playing anything in the car.
*
- * `PlaybackAdvancer` fetches first and reports Gone when it cannot, Android Auto included,
- * so the fallback had no callers left and was deleted. This is the ratchet: zero, not one.
+ * The invariant is not "never stream". The server's copy is the audio the offsets were
+ * measured against, and streaming it is as correct as playing the download. The invariant is
+ * that `mediaUrl` -- the address the *publisher* nominated -- has no business in the playback
+ * package at all. Downloading from it is a deliberate, explicit act and lives in the
+ * downloader, where the listener has asked for it.
*/
@Test
- fun `nothing builds a media item out of a remote url`() {
- val streaming = sources().flatMap { file ->
- linesOf(file).mapIndexedNotNull { index, line ->
- "${file.name}:${index + 1} ${line.trim()}".takeIf {
- line.contains("setUri(") && !line.contains("localPath")
+ fun `nothing in playback touches the publisher's enclosure url`() {
+ val offenders = sources()
+ .filter { it.path.contains("/playback/") }
+ .flatMap { file ->
+ linesOf(file).mapIndexedNotNull { index, line ->
+ "${file.name}:${index + 1} ${line.trim()}".takeIf { line.contains("mediaUrl") }
}
}
- }
assertTrue(
- "An episode played from anywhere but the app's own copy has the wrong advertising " +
- "in it, so the measured skips land in the wrong place:\n" + streaming.joinToString("\n"),
- streaming.isEmpty(),
- )
- }
-
- /**
- * Exactly one request in the app carries the skip server's token.
- *
- * `downloadOverHttp` fetches both the server's copy of an episode and the publisher's, and
- * only the first may be authenticated. Origin matching alone does not make that safe: an
- * enclosure URL comes out of a feed, so a publisher can point one at the server's own origin
- * and have the app spend the token on a path they chose. Provenance is the first gate and
- * `authenticated = true` is how it is spelled, so there should be one of it -- the call that
- * fetches what `ServerAudio.resolve` returned.
- */
- @Test
- fun `only one call site sends the server token`() {
- val authenticated = sources().flatMap { file ->
- linesOf(file).mapIndexedNotNull { index, line ->
- "${file.name}:${index + 1} ${line.trim()}".takeIf { line.contains("authenticated = true") }
- }
- }
-
- assertEquals(
- "Each of these fetches with Peter's server credentials. A feed chooses an episode's " +
- "mediaUrl, so any call that might carry one is a way to spend his token:\n" +
- authenticated.joinToString("\n"),
- 1,
- authenticated.size,
+ "The publisher's copy has different advertising in it, so the measured skips land " +
+ "in the wrong place. Play the download or the server's copy:\n" +
+ offenders.joinToString("\n"),
+ offenders.isEmpty(),
)
}
}
diff --git a/app/src/test/java/org/terst/listen/playback/BrowsedAudioTest.kt b/app/src/test/java/org/terst/listen/playback/BrowsedAudioTest.kt
new file mode 100644
index 0000000..97b73a7
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/BrowsedAudioTest.kt
@@ -0,0 +1,56 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * Which bytes an episode started from the car plays.
+ *
+ * Three attempts at this, and the middle one broke Peter's car. It began by falling back to the
+ * publisher's enclosure URL, which was wrong: a publisher serves a differently-ad-inserted copy,
+ * so the breaks the server measured land in the wrong places. Fixing that made the browse
+ * callback *download* the episode first -- tens of megabytes inside a future Android Auto is
+ * waiting on, so the car reported "failed to load", and the episode played later only because
+ * the download had finished in the background by then.
+ *
+ * The distinction that was missed both times: streaming is not the problem, streaming *the
+ * publisher's copy* is. The server's copy is the one the offsets were measured against, so it is
+ * as correct as the downloaded file and arrives immediately.
+ */
+class BrowsedAudioTest {
+
+ private val serverUrl = "https://listen.terst.org/audio/abc?token=t"
+
+ @Test
+ fun `a downloaded episode plays from the file`() {
+ assertEquals("/audio/a.mp3", BrowsedAudio.uriFor("/audio/a.mp3", serverUrl))
+ }
+
+ /** The file is preferred even when both exist: no network, and it is the same bytes. */
+ @Test
+ fun `the file wins over the server`() {
+ assertEquals("/audio/a.mp3", BrowsedAudio.uriFor("/audio/a.mp3", serverUrl))
+ }
+
+ @Test
+ fun `with no file the server's copy is streamed`() {
+ assertEquals(serverUrl, BrowsedAudio.uriFor(null, serverUrl))
+ }
+
+ /**
+ * Nothing, rather than the publisher. A car that plays nothing is a bad moment; a car that
+ * plays the wrong copy skips into the programme and gives no clue why.
+ */
+ @Test
+ fun `with no file and no server copy there is nothing to play`() {
+ assertNull(BrowsedAudio.uriFor(null, null))
+ }
+
+ @Test
+ fun `a blank path or url counts as absent`() {
+ assertEquals(serverUrl, BrowsedAudio.uriFor("", serverUrl))
+ assertNull(BrowsedAudio.uriFor("", ""))
+ assertNull(BrowsedAudio.uriFor(null, ""))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/BrowsedStartTest.kt b/app/src/test/java/org/terst/listen/playback/BrowsedStartTest.kt
index 7c8466a..f4eb9fe 100644
--- a/app/src/test/java/org/terst/listen/playback/BrowsedStartTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/BrowsedStartTest.kt
@@ -63,4 +63,46 @@ class BrowsedStartTest {
assertEquals(2, BrowsedStart.index(9, count = 3))
assertEquals(0, BrowsedStart.index(9, count = 0))
}
+
+ /*
+ Starting from the car plays the queue, not one episode.
+
+ Tapping an episode in Android Auto set the player's playlist to that single item. Two things
+ followed, and Peter reported both: the car's own Up Next list showed one episode rather than
+ the queue, and with one item in the playlist it looped instead of moving on.
+
+ The browse tree's "Up Next" node always listed the real queue -- this is the *player's*
+ playlist, which is a different thing and was never filled in.
+ */
+ @Test
+ fun `a queued episode brings the rest of the queue with it`() {
+ val queue = listOf("a", "b", "c")
+
+ assertEquals(queue, BrowsedStart.playlist(chosen = "b", queue = queue))
+ assertEquals(1, BrowsedStart.indexOf(chosen = "b", playlist = queue))
+ }
+
+ /** Starting at the head is the ordinary case and must not reorder anything. */
+ @Test
+ fun `starting at the head keeps the queue in order`() {
+ val queue = listOf("a", "b", "c")
+
+ assertEquals(queue, BrowsedStart.playlist(chosen = "a", queue = queue))
+ assertEquals(0, BrowsedStart.indexOf(chosen = "a", playlist = queue))
+ }
+
+ /**
+ * An episode browsed from a show is not in the queue, and must not drag the queue in behind
+ * it -- that would silently reorder what plays next.
+ */
+ @Test
+ fun `an episode outside the queue plays alone`() {
+ assertEquals(listOf("z"), BrowsedStart.playlist(chosen = "z", queue = listOf("a", "b")))
+ assertEquals(0, BrowsedStart.indexOf(chosen = "z", playlist = listOf("z")))
+ }
+
+ @Test
+ fun `an empty queue plays the chosen episode alone`() {
+ assertEquals(listOf("a"), BrowsedStart.playlist(chosen = "a", queue = emptyList()))
+ }
}
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
index 2023833..3d0e23f 100644
--- a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
@@ -330,132 +330,83 @@ class PlaybackAdvancerTest {
assertEquals(SubscriptionKind.PODCAST, (advancer.start("a") as? StartOutcome.NeedsFetch)?.kind)
}
- /*
- Starting from outside the app, where there is nobody to show a "fetching" label to.
-
- Android Auto, a Bluetooth button and system resumption all reach the session callback, which
- resolves a browse id straight to a media item. It never consulted the advancer about whether
- the audio existed, so an undownloaded episode was handed to the player as its publisher's URL
- and streamed -- in the car, which is where most of this library is actually listened to, and
- the one place the wrong-copy skips would be least obvious.
-
- The in-app paths show a label and fetch in two steps. A car cannot, so this composes the two.
- */
@Test
- fun `starting from outside the app fetches the audio first`() = runTest {
- show()
- episode("a")
- val fetching = PlaybackAdvancer(
- episodes, subscriptions, queue,
- sources = FakeSources(onFetch = { target ->
- episodes.updateDownload(target.id, DownloadState.DOWNLOADED, "/audio/a.mp3", now)
- Result.success(Unit)
- }),
- ) { now }
-
- val outcome = fetching.startFetchingIfNeeded("a")
+ fun `an episode whose show is gone and whose file is gone is not playable`() = runTest {
+ episode("a", subscriptionId = "vanished")
- assertEquals("/audio/a.mp3", (outcome as? StartOutcome.Ready)?.next?.episode?.localPath)
+ assertTrue(advancer.start("a") is StartOutcome.Gone)
}
- /** Already downloaded: no fetch, because the common case must not pay for the rare one. */
@Test
- fun `starting from outside the app does not refetch what is already there`() = runTest {
- show()
- episode("a", localPath = "/audio/a.mp3")
- var fetches = 0
- val fetching = PlaybackAdvancer(
- episodes, subscriptions, queue,
- sources = FakeSources(onFetch = { fetches++; Result.success(Unit) }),
- ) { now }
+ fun `an episode whose show is gone still plays from the file it has`() = runTest {
+ episode("a", subscriptionId = "vanished", localPath = "/audio/a.mp3")
- assertTrue(fetching.startFetchingIfNeeded("a") is StartOutcome.Ready)
- assertEquals(0, fetches)
+ assertTrue(advancer.start("a") is StartOutcome.Ready)
}
/**
- * A fetch that fails reports it rather than falling back to the publisher.
+ * A fetch that throws is a failure, not a crash.
*
- * Playing the publisher's copy is what this whole change is removing: it carries different
- * advertising, so the breaks measured on the server's copy land in the wrong place. Silence
- * the listener can retry beats skips that quietly cut the programme.
+ * fetchAndStart folds a Result, so anything the fetch *throws* went straight past it -- and
+ * a feed can carry an enclosure URL that OkHttp refuses to parse. One bad enclosure in one
+ * feed would otherwise stop episodes starting rather than failing its own.
*/
@Test
- fun `a fetch that fails from outside the app does not fall back to the publisher`() = runTest {
+ fun `a fetch that throws is reported rather than escaping`() = runTest {
show()
- episode("a")
- val fetching = PlaybackAdvancer(
+ val ep = episode("a")
+ val throwing = PlaybackAdvancer(
episodes, subscriptions, queue,
- sources = FakeSources(onFetch = { Result.failure(IllegalStateException("waiting for the server")) }),
+ sources = FakeSources(onFetch = { throw IllegalArgumentException("not a url") }),
) { now }
- assertTrue(fetching.startFetchingIfNeeded("a") is StartOutcome.FetchFailed)
+ assertTrue(throwing.fetchAndStart(ep, SubscriptionKind.PODCAST) is StartOutcome.FetchFailed)
}
/**
- * A fetch that throws is a failure, not a crash.
+ * A cancelled start is not a failed one.
*
- * fetchAndStart folds a Result, so anything the fetch *throws* went straight past it. Every
- * play goes through here now, so one feed with a malformed enclosure would stop episodes
- * starting at all rather than failing the one that is broken.
+ * The catch that stops a thrown fetch escaping is deliberately broad, and would otherwise
+ * swallow cancellation -- so navigating away mid-fetch would report a failure and leave a
+ * false error on screen. Structured concurrency relies on it propagating too.
*/
/**
- * An orphaned episode with no file has nothing to play and no way to get one.
+ * A failure with nothing to say still says something.
*
- * `start` reported Ready when the show was gone, because an unsubscribed show's episode
- * still plays from a file it already has. With no file there is no kind to fetch with
- * either, and the old answer was to stream the publisher. Nothing is the honest one, and it
- * is what lets toMediaItem stop carrying a remote fallback at all.
+ * `FetchFailed` carries the reason because it is the only place the real cause surfaces --
+ * "Sign in to confirm you're not a bot" and "video unavailable" are very different problems
+ * and look identical without it. Plenty of exceptions carry no message at all, and the
+ * fallback to the class name had never been exercised.
*/
@Test
- fun `an episode whose show is gone and whose file is gone is not playable`() = runTest {
- episode("a", subscriptionId = "vanished")
-
- assertTrue(advancer.start("a") is StartOutcome.Gone)
- }
+ fun `a fetch failing with no message still reports a reason`() = runTest {
+ show()
+ val ep = episode("a")
+ val nameless = PlaybackAdvancer(
+ episodes, subscriptions, queue,
+ sources = FakeSources(onFetch = { Result.failure(IllegalStateException()) }),
+ ) { now }
- @Test
- fun `an episode whose show is gone still plays from the file it has`() = runTest {
- episode("a", subscriptionId = "vanished", localPath = "/audio/a.mp3")
+ val outcome = nameless.fetchAndStart(ep, SubscriptionKind.PODCAST)
- assertTrue(advancer.start("a") is StartOutcome.Ready)
+ assertEquals("IllegalStateException", (outcome as? StartOutcome.FetchFailed)?.reason)
}
- /**
- * A cancelled start is not a failed one.
- *
- * The catch that stops a thrown fetch escaping is deliberately broad, which would otherwise
- * swallow cancellation too -- so navigating away mid-fetch would report "could not fetch
- * audio" and leave a false error on screen. Structured concurrency also relies on
- * CancellationException propagating.
- */
@Test
fun `a cancelled fetch stays cancelled rather than becoming a failure`() = runTest {
show()
- episode("a")
+ val ep = episode("a")
val cancelling = PlaybackAdvancer(
episodes, subscriptions, queue,
sources = FakeSources(onFetch = { throw CancellationException("navigated away") }),
) { now }
- val thrown = runCatching { cancelling.startFetchingIfNeeded("a") }.exceptionOrNull()
+ val thrown = runCatching { cancelling.fetchAndStart(ep, SubscriptionKind.PODCAST) }.exceptionOrNull()
assertTrue("got $thrown", thrown is CancellationException)
}
@Test
- fun `a fetch that throws is reported rather than escaping`() = runTest {
- show()
- episode("a")
- val throwing = PlaybackAdvancer(
- episodes, subscriptions, queue,
- sources = FakeSources(onFetch = { throw IllegalArgumentException("not a url") }),
- ) { now }
-
- assertTrue(throwing.startFetchingIfNeeded("a") is StartOutcome.FetchFailed)
- }
-
- @Test
fun `fetching then starting plays from the file yt-dlp wrote`() = runTest {
show(kind = SubscriptionKind.YOUTUBE_CHANNEL)
val episode = episode("a")