summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt15
-rw-r--r--app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt12
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedFetcher.kt12
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedResolver.kt45
-rw-r--r--app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt19
-rw-r--r--app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt26
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt11
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt7
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt5
-rw-r--r--app/src/main/java/org/terst/listen/playback/PodcastPlayer.kt56
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt22
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt22
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt21
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt40
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerBar.kt26
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerScreen.kt23
-rw-r--r--app/src/main/java/org/terst/listen/ui/QueueScreen.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt4
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt59
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt14
-rw-r--r--app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt52
-rw-r--r--app/src/test/java/org/terst/listen/feed/YouTubeFeedTest.kt83
23 files changed, 517 insertions, 65 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 4f2ca7b..e333551 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-61.15
+60.07
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index 2892d9d..958e2a5 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -10,7 +10,20 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface SubscriptionDao {
- @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
+ /**
+ * Most recently published first.
+ *
+ * Alphabetical put the show you are least likely to want next at the top as often as not;
+ * what a listener is looking for is whatever has something new. Shows with no episodes yet
+ * sort last rather than first, which is what COALESCE to 0 does here.
+ */
+ @Query(
+ """SELECT s.* FROM subscriptions s
+ LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest
+ FROM episodes GROUP BY subscriptionId) e
+ ON e.subscriptionId = s.id
+ ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE"""
+ )
fun observeAll(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
diff --git a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
index d07e026..3a87359 100644
--- a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
+++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
@@ -125,7 +125,17 @@ class EpisodeDownloader(
}
private companion object {
- const val USER_AGENT = "Listen/1.0 (Android podcast client)"
+ /**
+ * Sent on every outbound request.
+ *
+ * Carries the browser tokens because plenty of hosts gate on them: YouTube serves a 2KB stub to
+ * anything that doesn't look like a browser, and libsyn returns 403 for Go's default agent.
+ * This is content negotiation on public pages, not defeating an access control -- unlike a
+ * proof-of-origin token or a borrowed login, which Listen still doesn't do. Listen/1.0 stays on
+ * the end so the traffic is still attributable in a server log.
+ */
+ const val USER_AGENT =
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Listen/1.0"
const val MAX_NAME_LENGTH = 120
}
}
diff --git a/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt b/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
index 631f520..671953a 100644
--- a/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
+++ b/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
@@ -72,6 +72,16 @@ class FeedFetcher(private val client: OkHttpClient) {
private companion object {
const val NOT_MODIFIED = 304
- const val USER_AGENT = "Listen/1.0 (Android podcast client)"
+ /**
+ * Sent on every outbound request.
+ *
+ * Carries the browser tokens because plenty of hosts gate on them: YouTube serves a 2KB stub to
+ * anything that doesn't look like a browser, and libsyn returns 403 for Go's default agent.
+ * This is content negotiation on public pages, not defeating an access control -- unlike a
+ * proof-of-origin token or a borrowed login, which Listen still doesn't do. Listen/1.0 stays on
+ * the end so the traffic is still attributable in a server log.
+ */
+ const val USER_AGENT =
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Listen/1.0"
}
}
diff --git a/app/src/main/java/org/terst/listen/feed/FeedResolver.kt b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt
index 57baa51..07b0125 100644
--- a/app/src/main/java/org/terst/listen/feed/FeedResolver.kt
+++ b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt
@@ -39,15 +39,34 @@ class FeedResolver(
return resolveWeb(url)
}
- private fun resolveYouTube(url: String): Resolution =
- SubscriptionResolver.youtubeFeedUrl(url)?.let { Resolution.Feed(it) }
- // A handle or /c/ URL carries no channel id. Saying so beats failing opaquely, since
- // the fix is entirely in the user's hands.
- ?: Resolution.Failed(
- "That YouTube link doesn't contain a channel id. Open the channel and use the " +
- "URL with /channel/ in it, or share a playlist."
+ private suspend fun resolveYouTube(url: String): Resolution = withContext(Dispatchers.IO) {
+ if (!SubscriptionResolver.needsChannelLookup(url)) {
+ // The URL already names a channel or playlist.
+ return@withContext Resolution.Feed(SubscriptionResolver.youtubeFeedUrl(url)!!)
+ }
+
+ // A handle, /c/, /user/ or watch URL carries no channel id, and those are exactly the
+ // links YouTube's own share sheet produces. Telling the user to go find a /channel/
+ // URL made sharing a channel effectively impossible, so fetch the page and read the id
+ // off it instead.
+ val page = try {
+ fetch(url)
+ } catch (e: IOException) {
+ return@withContext Resolution.Failed(e.message ?: "Could not reach YouTube.")
+ }
+
+ val channelId = page?.let(SubscriptionResolver::channelIdFrom)
+ ?: return@withContext Resolution.Failed(
+ // YouTube serves a 2KB stub to non-browser user agents on watch pages, so a
+ // video link yields nothing to read. Not worked around by claiming to be a
+ // browser; a video link should add that one video, which is a separate feature.
+ "Couldn't find a channel on that link. Share the channel or a playlist -- a " +
+ "link to a single video isn't supported yet."
)
+ Resolution.Feed("https://www.youtube.com/feeds/videos.xml?channel_id=$channelId")
+ }
+
private suspend fun resolveApple(id: String): Resolution = withContext(Dispatchers.IO) {
val response = runCatching { fetch("$lookupBase?id=$id&entity=podcast") }
.getOrElse { return@withContext Resolution.Failed("Could not reach Apple: ${it.message}") }
@@ -123,6 +142,16 @@ class FeedResolver(
private data class LookupResult(@SerialName("feedUrl") val feedUrl: String? = null)
private companion object {
- const val USER_AGENT = "Listen/1.0 (Android podcast client)"
+ /**
+ * Sent on every outbound request.
+ *
+ * Carries the browser tokens because plenty of hosts gate on them: YouTube serves a 2KB stub to
+ * anything that doesn't look like a browser, and libsyn returns 403 for Go's default agent.
+ * This is content negotiation on public pages, not defeating an access control -- unlike a
+ * proof-of-origin token or a borrowed login, which Listen still doesn't do. Listen/1.0 stays on
+ * the end so the traffic is still attributable in a server log.
+ */
+ const val USER_AGENT =
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Listen/1.0"
}
}
diff --git a/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt b/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt
index 8b22bce..076d0f6 100644
--- a/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt
+++ b/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt
@@ -141,11 +141,20 @@ object PodcastFeedParser {
var imageUrl: String? = null
var chaptersUrl: String? = null
+ /** YouTube feeds carry a video id and a watch link instead of an enclosure. */
+ var videoId: String? = null
+ var watchUrl: String? = null
+
// Split by where the value comes from -- attributes or element text -- rather than
// one dispatch over every tag, which was complex enough for detekt to object to.
override fun readTag(parser: XmlPullParser, name: String) {
when (name) {
"enclosure" -> readEnclosure(parser)
+ "videoId" -> videoId = videoId ?: parser.text()
+ "thumbnail" -> imageUrl = imageUrl ?: parser.attr("url")
+ "link" -> if (parser.attr("rel") == "alternate") {
+ watchUrl = watchUrl ?: parser.attr("href")
+ }
"image" -> imageUrl = imageUrl ?: parser.attr("href")
"chapters" -> chaptersUrl = chaptersUrl ?: parser.attr("url")
else -> readTextTag(parser, name)
@@ -185,8 +194,14 @@ object PodcastFeedParser {
}
fun build(): ParsedEpisode? {
- val url = mediaUrl ?: return null
- val type = mediaType ?: return null
+ // A YouTube feed has no enclosure at all -- the audio URL only exists once yt-dlp
+ // resolves it at download time. Dropping these entries left every YouTube
+ // subscription permanently empty. The watch URL is what the downloader needs.
+ val youtubeUrl = videoId?.let { watchUrl ?: "https://www.youtube.com/watch?v=$it" }
+
+ val url = mediaUrl ?: youtubeUrl ?: return null
+ // yt-dlp is asked for bestaudio, which comes back as m4a.
+ val type = mediaType ?: (if (youtubeUrl != null) "audio/mp4" else null) ?: return null
return ParsedEpisode(
// Identity resolved last: a missing guid falls back to the enclosure URL, which
// is stable enough to keep a playback position attached to the right episode.
diff --git a/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt
index 6e96eda..fb0e13d 100644
--- a/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt
+++ b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt
@@ -19,6 +19,18 @@ object SubscriptionResolver {
private val YOUTUBE_CHANNEL = Regex("""/channel/(UC[\w-]+)""")
private val YOUTUBE_PLAYLIST = Regex("""[?&]list=([\w-]+)""")
+ /**
+ * A channel id as it appears on a YouTube page.
+ *
+ * Three shapes because the pages differ: a channel page carries the canonical link and an
+ * itemprop, a watch page only embeds it in JSON.
+ */
+ private val CHANNEL_ID_IN_PAGE = listOf(
+ Regex("""itemprop="identifier"\s+content="(UC[\w-]{10,})""""),
+ Regex(""""channelId"\s*:\s*"(UC[\w-]{10,})""""),
+ Regex("""/channel/(UC[\w-]{10,})"""),
+ )
+
private val FEED_TYPES = setOf(
"application/rss+xml",
"application/atom+xml",
@@ -64,6 +76,20 @@ object SubscriptionResolver {
}
/**
+ * Pulls a channel id out of a fetched YouTube page.
+ *
+ * Needed because the links YouTube's own share sheet produces -- a handle, a watch URL --
+ * carry no channel id at all, and rejecting them was the whole of "sharing a YouTube
+ * channel is absolutely impossible".
+ */
+ fun channelIdFrom(html: String): String? =
+ CHANNEL_ID_IN_PAGE.firstNotNullOfOrNull { it.find(html)?.groupValues?.get(1) }
+
+ /** A YouTube URL whose channel id can only come from fetching the page. */
+ fun needsChannelLookup(url: String): Boolean =
+ isYouTube(url) && youtubeFeedUrl(url) == null
+
+ /**
* Finds a feed declared by a show's web page.
*
* Parsed with a regex rather than an XML parser on purpose: this is HTML, which is usually
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 087d457..eeffe7c 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -44,14 +44,13 @@ class ListenSessionCallback(
.add(NotificationControls.seekForwardCommand)
.build()
- // A car head unit shows about three transport controls. Leave previous/next track
- // available and they take those slots, pushing skip-forward off the screen entirely --
- // which is exactly what happened: previous and skip-back, no skip-forward. For a single
- // episode "previous track" does nothing useful anyway, so the seek buttons get the room.
+ // Previous/next stay available -- removing them to free car-notification slots also
+ // removed the headphone skip button, which stopped working entirely. PodcastPlayer
+ // redefines them as skip-forward/back instead, so they are useful rather than absent.
+ // Only previous-media-item goes, since there is no previous item and he does not want
+ // the control.
val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
- .remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
- .remove(Player.COMMAND_SEEK_TO_NEXT)
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.build()
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 45d6e8c..5901b22 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -58,7 +58,8 @@ class PlaybackService : MediaLibraryService() {
// A library session rather than a plain one: Android Auto only treats an app as
// driving-friendly if it can browse a media library, and without that the head unit
// can't start or choose anything.
- session = MediaLibrarySession.Builder(this, exo, ListenSessionCallback(this, scope))
+ // Wrapped so next/previous mean skip-forward/back by seconds; see PodcastPlayer.
+ session = MediaLibrarySession.Builder(this, PodcastPlayer(exo), ListenSessionCallback(this, scope))
.setCustomLayout(NotificationControls.customLayout())
.build()
@@ -134,7 +135,9 @@ fun EpisodeEntity.toMediaItem(
.setTitle(title)
// A lock screen and a car head unit show artist and album, not just title.
// Left unset they render as blank lines under the episode name.
- .setArtist(author ?: showTitle)
+ // Show name first. NPR sets itunes:author to "NPR" on every one of its feeds,
+ // so preferring the author made every NPR show display as "NPR".
+ .setArtist(showTitle ?: author)
.setAlbumTitle(showTitle)
// Many feeds only set the image at channel level, not per episode, so the
// show's image is the fallback here -- and the single point everything
diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
index b3644d3..d1e788e 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
data class NowPlaying(
val episodeId: String?,
val title: String?,
+ /** The show, which "which podcast is this?" needs and the episode title rarely answers. */
+ val showTitle: String? = null,
val isPlaying: Boolean,
val positionMs: Long,
val durationMs: Long?,
@@ -44,7 +46,7 @@ class PlayerConnection(private val context: Context) {
var onEpisodeFinished: ((String) -> Unit)? = null
private val _nowPlaying = MutableStateFlow(
- NowPlaying(null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f)
+ NowPlaying(null, null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f)
)
val nowPlaying: StateFlow<NowPlaying> = _nowPlaying.asStateFlow()
@@ -146,6 +148,7 @@ class PlayerConnection(private val context: Context) {
_nowPlaying.value = NowPlaying(
episodeId = player?.currentMediaItem?.mediaId,
title = player?.currentMediaItem?.mediaMetadata?.title?.toString(),
+ showTitle = player?.currentMediaItem?.mediaMetadata?.albumTitle?.toString(),
isPlaying = player?.isPlaying == true,
positionMs = player?.currentPosition ?: 0,
durationMs = player?.duration?.takeIf { it > 0 },
diff --git a/app/src/main/java/org/terst/listen/playback/PodcastPlayer.kt b/app/src/main/java/org/terst/listen/playback/PodcastPlayer.kt
new file mode 100644
index 0000000..c0c28c5
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/PodcastPlayer.kt
@@ -0,0 +1,56 @@
+package org.terst.listen.playback
+
+import androidx.annotation.OptIn
+import androidx.media3.common.ForwardingPlayer
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+
+/**
+ * Makes "next"/"previous" mean skip-forward/back by seconds.
+ *
+ * Headphone and Bluetooth remotes send next/previous track. Listen loads one episode at a time,
+ * so those commands had nothing to act on — and removing them to free up car-notification slots
+ * took the headphone skip button with them, which is how skip-forward stopped working on
+ * headphones.
+ *
+ * Keeping the commands available but redefining them is what satisfies both: the buttons exist
+ * for anything that expects them, and on a podcast they do the thing a listener actually wants.
+ */
+@OptIn(UnstableApi::class)
+class PodcastPlayer(player: Player) : ForwardingPlayer(player) {
+
+ override fun seekToNext() = seekForward()
+
+ override fun seekToPrevious() = seekBack()
+
+ override fun seekToNextMediaItem() = seekForward()
+
+ override fun seekToPreviousMediaItem() = seekBack()
+
+ /**
+ * Reports next/previous as always available.
+ *
+ * A remote hides or greys a button whose command is unavailable, and with a single loaded
+ * item Media3 would report both as unavailable — which is the state that made the headphone
+ * button do nothing.
+ */
+ override fun getAvailableCommands(): Player.Commands =
+ super.getAvailableCommands().buildUpon()
+ .addAll(
+ Player.COMMAND_SEEK_TO_NEXT,
+ Player.COMMAND_SEEK_TO_PREVIOUS,
+ Player.COMMAND_SEEK_FORWARD,
+ Player.COMMAND_SEEK_BACK,
+ )
+ .build()
+
+ override fun isCommandAvailable(command: Int): Boolean = when (command) {
+ Player.COMMAND_SEEK_TO_NEXT,
+ Player.COMMAND_SEEK_TO_PREVIOUS,
+ Player.COMMAND_SEEK_FORWARD,
+ Player.COMMAND_SEEK_BACK,
+ -> true
+
+ else -> super.isCommandAvailable(command)
+ }
+}
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 e9148c4..20e46cc 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -113,6 +113,7 @@ private fun DownloadIcon(state: DownloadState, onDownload: () -> Unit) {
/** Duration, how far in, and download state in one line. */
internal fun EpisodeEntity.progressLabel(): String {
val parts = mutableListOf<String>()
+ publishedAtMs?.let { parts += formatReleaseDate(it) }
durationMs?.let { parts += formatDuration(it) }
if (positionMs > 0) parts += "${formatDuration(positionMs)} in"
if (playedAtMs != null) parts += "played"
@@ -120,6 +121,27 @@ internal fun EpisodeEntity.progressLabel(): String {
return parts.joinToString(" · ")
}
+/**
+ * Relative for the last week, then absolute.
+ *
+ * "3d ago" answers "is this the new one?" at a glance, which is the question in an episode list;
+ * past a week the actual date is more use than a growing day count.
+ */
+private fun formatReleaseDate(publishedAtMs: Long): String {
+ val ageMs = System.currentTimeMillis() - publishedAtMs
+ val days = TimeUnit.MILLISECONDS.toDays(ageMs)
+ return when {
+ ageMs < 0 -> dateFormat.format(java.util.Date(publishedAtMs))
+ days == 0L -> "today"
+ days == 1L -> "yesterday"
+ days < DAYS_BEFORE_ABSOLUTE -> "${days}d ago"
+ else -> dateFormat.format(java.util.Date(publishedAtMs))
+ }
+}
+
+private const val DAYS_BEFORE_ABSOLUTE = 7L
+private val dateFormat = java.text.SimpleDateFormat("d MMM yyyy", java.util.Locale.getDefault())
+
private const val MINUTES_PER_HOUR = 60
private fun formatDuration(ms: Long): String {
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 263eee0..1ff29f6 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -131,6 +131,28 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
}
}
+ /** Refreshes one subscription, for when you are looking at it and want it checked now. */
+ fun refresh(subscriptionId: String) {
+ if (_refreshing.value) return
+ _refreshing.value = true
+ viewModelScope.launch {
+ val subscription = graph.subscriptions.getById(subscriptionId)
+ if (subscription == null) {
+ _refreshing.value = false
+ return@launch
+ }
+ val outcome = graph.repository.refresh(subscription)
+ _refreshing.value = false
+ _banner.value = when (outcome) {
+ is RefreshOutcome.Failed -> Banner(outcome.message, isError = true)
+ is RefreshOutcome.Updated ->
+ if (outcome.newEpisodes > 0) Banner("${outcome.newEpisodes} new episode(s)")
+ else Banner("Up to date")
+ RefreshOutcome.Unchanged -> Banner("Up to date")
+ }
+ }
+ }
+
fun refreshAll() {
if (_refreshing.value) return
_refreshing.value = true
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 431343c..61416a3 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -63,16 +63,29 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
player = player, onBack = { nav.popBackStack() })
}
composable("queue") {
- QueueScreen(vm = viewModel,
- player = player, onBack = { nav.popBackStack() })
+ QueueScreen(
+ vm = viewModel,
+ player = player,
+ onOpenPlayer = { nav.navigate("player") },
+ onOpenQueue = {},
+ onBack = { nav.popBackStack() },
+ )
}
composable("subscription/{id}") { entry ->
val id = entry.arguments?.getString("id")
if (id == null) {
nav.popBackStack()
} else {
- SubscriptionScreen(vm = viewModel,
- player = player, subscriptionId = id, onBack = { nav.popBackStack() })
+ SubscriptionScreen(
+ vm = viewModel,
+ player = player,
+ subscriptionId = id,
+ nav = SubscriptionNav(
+ openPlayer = { nav.navigate("player") },
+ openQueue = { nav.navigate("queue") },
+ back = { nav.popBackStack() },
+ ),
+ )
}
}
}
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 4031188..450d70a 100644
--- a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
@@ -50,7 +50,9 @@ fun NowPlayingBar(state: NowPlaying, actions: NowPlayingActions) {
}
Row(
- Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
+ // Taller than it was: the controls are used one-handed, often without looking,
+ // and the old 4dp made a cramped strip with small targets.
+ Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -58,24 +60,13 @@ fun NowPlayingBar(state: NowPlaying, actions: NowPlayingActions) {
model = state.artworkUrl,
contentDescription = null,
modifier = Modifier
- .size(40.dp)
+ .size(56.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = actions.openPlayer),
)
- Column(Modifier.weight(1f).clickable(onClick = actions.openPlayer).padding(8.dp)) {
- Text(
- state.title ?: "Playing",
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- "${formatClock(state.positionMs)} · ${state.speed}×",
- style = MaterialTheme.typography.bodySmall,
- )
- }
+ NowPlayingText(state, Modifier.weight(1f), actions.openPlayer)
IconButton(onClick = actions.skipBack) {
Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
@@ -112,3 +103,24 @@ internal fun formatClock(ms: Long): String {
"%d:%02d".format(minutes, seconds)
}
}
+
+/** Title and show, tappable to open the player. */
+@Composable
+private fun NowPlayingText(state: NowPlaying, modifier: Modifier, onOpen: () -> Unit) {
+ Column(modifier.clickable(onClick = onOpen).padding(horizontal = 10.dp)) {
+ Text(
+ state.title ?: "Playing",
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ // Show name as well: "which podcast is this?" is the thing the bar could not answer.
+ listOfNotNull(state.showTitle, "${formatClock(state.positionMs)} · ${state.speed}×")
+ .joinToString(" · "),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
new file mode 100644
index 0000000..fd526c0
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt
@@ -0,0 +1,26 @@
+package org.terst.listen.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+
+/**
+ * The now-playing bar as a screen's bottom bar.
+ *
+ * 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) {
+ val nowPlaying by player.nowPlaying.collectAsState()
+ NowPlayingBar(
+ state = nowPlaying,
+ actions = NowPlayingActions(
+ togglePlayPause = player::togglePlayPause,
+ skipBack = player::skipBack,
+ skipForward = player::skipForward,
+ openPlayer = onOpenPlayer,
+ 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
index 1fe2f67..1af21c5 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
@@ -3,6 +3,7 @@ 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
@@ -149,13 +150,29 @@ private fun Scrubber(positionMs: Long, durationMs: Long?, onSeek: (Long) -> Unit
@Composable
private fun SpeedRow(current: Float, onSelect: (Float) -> Unit) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // 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) }) { Text("${speed}×") }
+ Button(onClick = { onSelect(speed) }, contentPadding = SPEED_PADDING) { Text(label) }
} else {
- OutlinedButton(onClick = { onSelect(speed) }) { Text("${speed}×") }
+ 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. */
+private 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/QueueScreen.kt b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
index cea4630..75fbaca 100644
--- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
@@ -45,8 +45,11 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) {
+fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel,
+ onOpenPlayer: () -> Unit,
+ onOpenQueue: () -> Unit, onBack: () -> Unit) {
val queue by vm.queue.collectAsState()
+ val nowPlaying by player.nowPlaying.collectAsState()
val subscriptions by vm.subscriptions.collectAsState()
val subscriptionImages = remember(subscriptions) {
subscriptions.associate { it.id to it.imageUrl }
@@ -73,6 +76,7 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit
},
)
},
+ bottomBar = { PlayerBar(player, onOpenPlayer, onOpenQueue) },
) { padding ->
LazyColumn(state = lazyListState, modifier = Modifier.padding(padding).fillMaxSize()) {
items(localOrder, key = { it.id }) { episode ->
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
index 127f5db..a16e3d5 100644
--- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -4,6 +4,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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
@@ -83,7 +84,8 @@ fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> U
@Composable
private fun SpeedSection(current: Float, onSelect: (Float) -> Unit) {
Section("Playback speed") {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Wraps rather than one fixed row: five speed chips overflowed the width on a phone.
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SPEEDS.forEach { speed ->
if (current == speed) {
Button(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") }
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 fbcc2db..c8d71ea 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -59,28 +59,25 @@ fun SubscriptionScreen(
vm: ListenViewModel,
player: PlayerViewModel,
subscriptionId: String,
- onBack: () -> Unit,
+ nav: SubscriptionNav,
) {
val episodes by vm.episodesOf(subscriptionId).collectAsState(initial = emptyList())
var confirming by remember { mutableStateOf(false) }
+ val refreshing by vm.refreshing.collectAsState()
+ val nowPlaying by player.nowPlaying.collectAsState()
val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId }
Scaffold(
topBar = {
- TopAppBar(
- title = { Text(subscription?.title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) },
- navigationIcon = {
- IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
- }
- },
- actions = {
- IconButton(onClick = { confirming = true }) {
- Icon(Icons.Default.Delete, contentDescription = "Unsubscribe")
- }
- },
+ SubscriptionTopBar(
+ title = subscription?.title,
+ refreshing = refreshing,
+ onRefresh = { vm.refresh(subscriptionId) },
+ onUnsubscribe = { confirming = true },
+ onBack = nav.back,
)
},
+ bottomBar = { PlayerBar(player, nav.openPlayer, 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.
@@ -91,7 +88,7 @@ fun SubscriptionScreen(
onConfirm = {
confirming = false
vm.unsubscribe(subscriptionId)
- onBack()
+ nav.back()
},
)
@@ -161,3 +158,37 @@ private fun UnsubscribeDialog(
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
+
+/** Navigation out of this screen, bundled so the signature stays readable. */
+data class SubscriptionNav(
+ val openPlayer: () -> Unit,
+ val openQueue: () -> Unit,
+ val back: () -> Unit,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SubscriptionTopBar(
+ title: String?,
+ refreshing: Boolean,
+ onRefresh: () -> Unit,
+ onUnsubscribe: () -> Unit,
+ onBack: () -> Unit,
+) {
+ TopAppBar(
+ title = { Text(title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = onRefresh, enabled = !refreshing) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh this podcast")
+ }
+ IconButton(onClick = onUnsubscribe) {
+ Icon(Icons.Default.Delete, contentDescription = "Unsubscribe")
+ }
+ },
+ )
+}
diff --git a/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt b/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt
index 7a819f9..8967c49 100644
--- a/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt
+++ b/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt
@@ -183,12 +183,16 @@ class FeedResolverTest {
assertEquals("should not need the network", 0, server.requestCount)
}
- /** A handle URL carries no channel id, so say what to do instead of failing opaquely. */
+ /**
+ * Handle and watch URLs used to be rejected outright, which is what made sharing a channel
+ * "absolutely impossible" -- they are the links YouTube's own share sheet produces. The
+ * id now comes from the page, so the pure parts are asserted here and the fetch path is
+ * exercised on a device (see YouTubeResolveTest).
+ */
@Test
- fun `a youtube handle explains what is needed`() = runTest {
- val failed = resolve("https://www.youtube.com/@SomeHandle") as FeedResolver.Resolution.Failed
- assertTrue("should mention the channel URL: '${failed.message}'",
- failed.message.contains("channel", ignoreCase = true))
+ fun `a handle url is recognised as needing a page lookup`() = runTest {
+ assertTrue(SubscriptionResolver.needsChannelLookup("https://youtube.com/@lastweektonight?si=abc"))
+ assertEquals(0, server.requestCount)
}
// --- Apple Podcasts --------------------------------------------------------------------
diff --git a/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt
index c219dcf..638816d 100644
--- a/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt
+++ b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt
@@ -106,6 +106,58 @@ class SubscriptionResolverTest {
assertTrue(!SubscriptionResolver.isYouTube("https://example.com/youtube"))
}
+ /**
+ * Peter shared three YouTube links and called subscribing "absolutely impossible". The
+ * forms YouTube's own share sheet produces -- a handle, and a link with an ?si= tracking
+ * parameter -- carry no channel id at all, so they have to come from the page.
+ */
+ @Test
+ fun `extracts a channel id from a handle page`() {
+ val html = """<html><head><meta itemprop="identifier" content="UC3XTzVzaHQEd30rQbuvCtTQ">
+ <link rel="canonical" href="https://www.youtube.com/channel/UC3XTzVzaHQEd30rQbuvCtTQ"></head></html>"""
+ assertEquals("UC3XTzVzaHQEd30rQbuvCtTQ", SubscriptionResolver.channelIdFrom(html))
+ }
+
+ @Test
+ fun `extracts a channel id from a watch page`() {
+ val html = """<html><body><script>{"channelId":"UCW7FMS1tAzMHM7eE14fbXcg","x":1}</script></body></html>"""
+ assertEquals("UCW7FMS1tAzMHM7eE14fbXcg", SubscriptionResolver.channelIdFrom(html))
+ }
+
+ @Test
+ fun `extracts a channel id from a canonical link alone`() {
+ val html = """<link rel="canonical" href="https://www.youtube.com/channel/UCabc123_-xYZ01234567890">"""
+ assertEquals("UCabc123_-xYZ01234567890", SubscriptionResolver.channelIdFrom(html))
+ }
+
+ @Test
+ fun `returns null when a page carries no channel id`() {
+ assertNull(SubscriptionResolver.channelIdFrom("<html><body>nothing here</body></html>"))
+ assertNull(SubscriptionResolver.channelIdFrom(""))
+ }
+
+ /** Only real channel ids: they always start UC and are otherwise word characters. */
+ @Test
+ fun `ignores things that merely look like a channel id`() {
+ assertNull(SubscriptionResolver.channelIdFrom("""<a href="/channel/NOTACHANNEL">x</a>"""))
+ }
+
+ @Test
+ fun `a handle url is recognised as needing a lookup`() {
+ assertTrue(SubscriptionResolver.needsChannelLookup("https://youtube.com/@lastweektonight?si=abc"))
+ assertTrue(SubscriptionResolver.needsChannelLookup("https://www.youtube.com/c/somename"))
+ assertTrue(SubscriptionResolver.needsChannelLookup("https://www.youtube.com/user/somename"))
+ assertTrue(SubscriptionResolver.needsChannelLookup("https://www.youtube.com/watch?v=abc123"))
+ assertTrue(SubscriptionResolver.needsChannelLookup("https://youtu.be/abc123"))
+ }
+
+ @Test
+ fun `urls that already carry an id need no lookup`() {
+ assertTrue(!SubscriptionResolver.needsChannelLookup("https://m.youtube.com/channel/UCabc"))
+ assertTrue(!SubscriptionResolver.needsChannelLookup("https://youtube.com/playlist?list=PLabc"))
+ assertTrue(!SubscriptionResolver.needsChannelLookup("https://example.com/whatever"))
+ }
+
// --- HTML feed discovery --------------------------------------------------------------
@Test
diff --git a/app/src/test/java/org/terst/listen/feed/YouTubeFeedTest.kt b/app/src/test/java/org/terst/listen/feed/YouTubeFeedTest.kt
new file mode 100644
index 0000000..e8cfe79
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/YouTubeFeedTest.kt
@@ -0,0 +1,83 @@
+package org.terst.listen.feed
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * YouTube's feeds are Atom and carry no <enclosure> at all -- just a video id and a watch link.
+ * The parser dropped every entry for having no playable media, so a YouTube subscription added
+ * successfully and then sat permanently empty. Reported: "subscribed but yields an empty feed".
+ *
+ * The audio URL genuinely isn't in the feed; it's resolved by yt-dlp at download time. What the
+ * parser has to preserve is enough to get there.
+ */
+class YouTubeFeedTest {
+
+ private val feed = """<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015"
+ xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
+ <title>LastWeekTonight</title>
+ <entry>
+ <id>yt:video:h1pXm0GjLho</id>
+ <yt:videoId>h1pXm0GjLho</yt:videoId>
+ <yt:channelId>UC3XTzVzaHQEd30rQbuvCtTQ</yt:channelId>
+ <title>UnitedHealthcare &amp; UnitedHealth Group</title>
+ <link rel="alternate" href="https://www.youtube.com/watch?v=h1pXm0GjLho"/>
+ <published>2026-09-21T06:30:26+00:00</published>
+ <media:group>
+ <media:thumbnail url="https://i1.ytimg.com/vi/h1pXm0GjLho/hqdefault.jpg"/>
+ <media:description>With Luigi</media:description>
+ </media:group>
+ </entry>
+</feed>""".trim()
+
+ private fun parsed() = PodcastFeedParser.parse(feed.byteInputStream())
+
+ @Test
+ fun `a youtube entry becomes an episode`() {
+ val episodes = parsed().episodes
+ assertEquals("every entry was being dropped for having no enclosure", 1, episodes.size)
+ }
+
+ @Test
+ fun `the media url is the watch url yt-dlp can resolve`() {
+ val episode = parsed().episodes.single()
+ assertEquals("https://www.youtube.com/watch?v=h1pXm0GjLho", episode.mediaUrl)
+ }
+
+ @Test
+ fun `the episode keeps its title and date`() {
+ val episode = parsed().episodes.single()
+ assertEquals("UnitedHealthcare & UnitedHealth Group", episode.title)
+ assertNotNull("the published date must survive", episode.publishedAtMs)
+ }
+
+ /** The video id is the stable identity; the watch URL merely contains it. */
+ @Test
+ fun `the guid is stable`() {
+ assertEquals("yt:video:h1pXm0GjLho", parsed().episodes.single().guid)
+ }
+
+ @Test
+ fun `the thumbnail becomes episode artwork`() {
+ assertEquals(
+ "https://i1.ytimg.com/vi/h1pXm0GjLho/hqdefault.jpg",
+ parsed().episodes.single().imageUrl,
+ )
+ }
+
+ @Test
+ fun `the feed title survives`() {
+ assertEquals("LastWeekTonight", parsed().title)
+ }
+
+ /** A podcast item with no enclosure is still not an episode -- this must not loosen that. */
+ @Test
+ fun `a non-youtube item without media is still dropped`() {
+ val rss = """<?xml version="1.0"?><rss version="2.0"><channel><title>T</title>
+ <item><title>No media here</title><guid>g1</guid></item></channel></rss>""".trim()
+ assertTrue(PodcastFeedParser.parse(rss.byteInputStream()).episodes.isEmpty())
+ }
+}