From 06939defdbf144e630b5435bba552a730461440b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 26 Sep 2026 18:25:45 +0000 Subject: Hide played episodes, and hand off to the video at the right moment Two of Peter's asks. Played episodes are hidden by default. An episode you have heard is done with, and leaving it in the list buries the ones that are not. A global toggle on the show screen brings them back, so a mis-tapped "played" can still be found -- stored in DataStore, which has been a declared dependency since phase 1 with nothing using it. Android Auto always hides them regardless of the toggle: a car list is scrolled blind at a traffic light. Hand-off carries the position both ways. Out: "Watch from here" opens the video on its own site at the point reached. In: a link shared from YouTube with a timestamp on it was shared at that moment deliberately, so the episode starts there rather than at the top. WatchLink does both and is pure, because an unrecognised URL shape fails silently -- as a link that simply starts from the beginning -- and YouTube produces a lot of shapes. The button sits on the episode screen as well as the player. That was not the first design, and testing changed it: the player-only version is unavailable exactly when it is most wanted, because an episode YouTube declines to serve never starts playing and so never reaches the player at all. Verified on a device, round trip: youtu.be/dQw4w9WgXcQ?t=90 shared in stored a position of 90000, the episode screen offered Watch, and tapping it fired youtube.com/watch?v=dQw4w9WgXcQ&t=90s. Marking an episode played removed it from the list, the toggle brought it back, and the preference survived a restart. An ordinary podcast offers no hand-off, having no page to open. Two things caught on the way. A crash: the episode cache was declared after the init block that collects from a StateFlow, which emits synchronously, so it was still null on the first emission -- caught by the launch check, now a plain field declared with the others. And the played switch needed a contentDescription distinct from its visible label, or it is ambiguous to pick -- the same trap the drip switch hit. Preferences joins the coverage exclusions: a Context, a file and a generated Flow, deciding nothing. WatchLink is fully covered. No permission changes, no schema change. 442 unit tests green, detekt and lint clean, coverage steady at 90.13. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .coverage-baseline | 2 +- CLAUDE.md | 11 ++ app/build.gradle.kts | 3 + app/src/main/java/org/terst/listen/data/Daos.kt | 19 +++- .../main/java/org/terst/listen/data/Preferences.kt | 37 +++++++ .../java/org/terst/listen/data/Repositories.kt | 2 + .../terst/listen/data/SubscriptionRepository.kt | 3 + .../java/org/terst/listen/feed/FeedResolver.kt | 6 ++ .../main/java/org/terst/listen/feed/WatchLink.kt | 63 ++++++++++++ .../main/java/org/terst/listen/ui/EpisodeScreen.kt | 2 + .../java/org/terst/listen/ui/ListenViewModel.kt | 13 ++- .../main/java/org/terst/listen/ui/PlayerScreen.kt | 7 ++ .../java/org/terst/listen/ui/PlayerViewModel.kt | 22 ++++ .../java/org/terst/listen/ui/SubscriptionScreen.kt | 57 ++++++++--- .../main/java/org/terst/listen/ui/WatchButton.kt | 29 ++++++ .../test/java/org/terst/listen/data/FakeDaos.kt | 15 ++- .../java/org/terst/listen/feed/WatchLinkTest.kt | 114 +++++++++++++++++++++ 17 files changed, 381 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/org/terst/listen/data/Preferences.kt create mode 100644 app/src/main/java/org/terst/listen/feed/WatchLink.kt create mode 100644 app/src/main/java/org/terst/listen/ui/WatchButton.kt create mode 100644 app/src/test/java/org/terst/listen/feed/WatchLinkTest.kt diff --git a/.coverage-baseline b/.coverage-baseline index 0670581..28953be 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -90.12 +90.13 diff --git a/CLAUDE.md b/CLAUDE.md index 67b228f..0316ec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,6 +234,17 @@ out of a possibly-partial MP3, and the JSON path covers the shows that mark chap come from. Only then: a title that was shared is the one that was meant, and the probe costs a network round trip. +- **Played episodes are hidden by default**, globally, via a DataStore preference the show + screen toggles. Android Auto always hides them regardless: a car list is scrolled blind at a + traffic light, and something already heard is pure noise there. + +- **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 + episode that cannot be fetched never starts playing, and that is exactly when watching it + elsewhere is the point. + - **Anything entering the queue is downloaded immediately, on any network.** The hook is `QueueRepository`'s `onQueued`, fired with newly-present ids only, because the queue is the one choke point every route goes through. The one-off job deliberately has no network diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcbe4f9..f0ae29f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -275,6 +275,9 @@ val coverageExclusions = listOf( "**/work/RetentionWorker*", "**/work/Scheduling*", + // DataStore: a Context, a file and a generated Flow. Nothing here decides anything. + "**/data/Preferences*", + // The embedded CPython bridge, and the app/graph wiring that only constructs things. "**/download/ChaquopyAudioSource*", "**/youtube/YouTubeAudio*", 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 abc7ea3..8b7b322 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -100,20 +100,35 @@ interface EpisodeDao { * [dripEnabled] hides everything slow-drip has not released yet -- the synthetic released-set * a drip subscription shows instead of the raw feed. It is a parameter rather than a join so * that turning drip off needs no data change: the column is simply ignored. + * + * [showPlayed] is off by default: an episode you have heard is done with, and leaving it in + * the list buries the ones that are not. The toggle exists so a mis-tapped "played" can be + * found again. */ @Query( """SELECT * FROM episodes WHERE subscriptionId = :subscriptionId AND (:dripEnabled = 0 OR dripReleasedAtMs IS NOT NULL) + AND (:showPlayed = 1 OR playedAtMs IS NULL) ORDER BY publishedAtMs DESC""" ) - fun observeVisible(subscriptionId: String, dripEnabled: Boolean): Flow> + fun observeVisible( + subscriptionId: String, + dripEnabled: Boolean, + showPlayed: Boolean, + ): Flow> - /** The same set, for Android Auto's browse tree, which cannot observe a Flow. */ + /** + * The same set for Android Auto, which cannot observe a Flow. + * + * Played episodes are always left out here rather than following the phone's toggle: a car + * list is scrolled blind at a traffic light, and something already heard is pure noise. + */ @Query( """SELECT * FROM episodes WHERE subscriptionId = :subscriptionId AND (:dripEnabled = 0 OR dripReleasedAtMs IS NOT NULL) + AND playedAtMs IS NULL ORDER BY publishedAtMs DESC""" ) suspend fun getVisible(subscriptionId: String, dripEnabled: Boolean): List diff --git a/app/src/main/java/org/terst/listen/data/Preferences.kt b/app/src/main/java/org/terst/listen/data/Preferences.kt new file mode 100644 index 0000000..1f7e14f --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/Preferences.kt @@ -0,0 +1,37 @@ +package org.terst.listen.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.store by preferencesDataStore(name = "listen-preferences") + +/** + * The handful of settings that are a view preference rather than data. + * + * Not in Room: these describe how the library is being looked at, not what is in it, and a + * schema migration for a boolean nobody else reads would be the wrong shape. DataStore has been + * a declared dependency since phase 1 with nothing using it; this is what it is for. + */ +class Preferences(private val context: Context) { + + /** + * Whether episodes already heard stay in a show's list. + * + * Off by default -- an episode you have heard is done with, and leaving it in the list + * buries the ones that are not. The toggle exists so a mis-tapped "played" can be found + * again, and it is global rather than per-show because it is about how you read a list. + */ + val showPlayed: Flow = context.store.data.map { it[SHOW_PLAYED] ?: false } + + suspend fun setShowPlayed(value: Boolean) { + context.store.edit { it[SHOW_PLAYED] = value } + } + + private companion object { + val SHOW_PLAYED = booleanPreferencesKey("show_played") + } +} diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index 8c994a1..cb017ac 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -57,6 +57,8 @@ class AppGraph(private val context: Context) { val resolver: FeedResolver by lazy { FeedResolver(client) } + val preferences: Preferences by lazy { Preferences(context) } + val ruleApplier: RuleApplier by lazy { RuleApplier(ruleDao, episodes, queue) } val drip: DripReleaser by lazy { DripReleaser(subscriptions, episodes, queue) } diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt index 0e104d4..1afb861 100644 --- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt +++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt @@ -212,6 +212,9 @@ class SubscriptionRepository( publishedAtMs = now(), imageUrl = video.imageUrl, discoveredAtMs = now(), + // A link shared with a timestamp was shared at that moment deliberately, + // so the episode resumes there rather than starting from the top. + positionMs = video.startPositionMs ?: 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 b7dafea..f988c52 100644 --- a/app/src/main/java/org/terst/listen/feed/FeedResolver.kt +++ b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt @@ -43,6 +43,8 @@ class FeedResolver( val author: String?, /** Which extractor route the audio comes down; decides where the episode lands. */ val kind: SubscriptionKind = SubscriptionKind.YOUTUBE_CHANNEL, + /** Where the shared link said to start, if it carried a timestamp. */ + val startPositionMs: Long? = null, ) : Resolution data class Failed(val message: String) : Resolution @@ -85,6 +87,9 @@ class FeedResolver( imageUrl = meta?.imageUrl, durationMs = meta?.durationMs, author = meta?.author, + // Shared at a moment on purpose -- the original URL, before it was rewritten + // to the canonical form above, is the only place that says so. + startPositionMs = WatchLink.startPositionMs(url), ) } @@ -124,6 +129,7 @@ class FeedResolver( durationMs = null, author = null, kind = SubscriptionKind.FACEBOOK_VIDEO, + startPositionMs = WatchLink.startPositionMs(url), ) } diff --git a/app/src/main/java/org/terst/listen/feed/WatchLink.kt b/app/src/main/java/org/terst/listen/feed/WatchLink.kt new file mode 100644 index 0000000..0fa019a --- /dev/null +++ b/app/src/main/java/org/terst/listen/feed/WatchLink.kt @@ -0,0 +1,63 @@ +package org.terst.listen.feed + +/** + * Carries a position between Listen and the site a video came from. + * + * Both directions. Out: hand off to YouTube at the point you had reached, so switching to the + * picture does not mean hunting for your place. In: a link shared from YouTube with a timestamp + * on it was shared at that moment on purpose, so the episode should start there. + * + * Pure, so every URL shape is pinned without a device -- which matters here because a shape + * that goes unrecognised fails silently, as a link that simply starts from the beginning. + */ +object WatchLink { + + private const val MS_PER_SECOND = 1000L + private const val SECONDS_PER_MINUTE = 60L + private const val SECONDS_PER_HOUR = 3600L + private val SECONDS_PARAM = Regex("""[?&](?:t|start)=([^&#]+)""") + + /** `1h2m3s`, `1m30s`, `90s` or `90`. */ + private val CLOCK = Regex("""^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$""") + + /** + * A link to the same moment on the original site, or null if there is no page to open. + * + * An ordinary podcast enclosure returns null: there is nothing to hand off to. + */ + fun at(mediaUrl: String, positionMs: Long): String? { + val base = canonical(mediaUrl) ?: return null + val seconds = positionMs / MS_PER_SECOND + if (seconds <= 0) return base + + // YouTube wants the `s`; Facebook wants a bare number. + return if (YouTubeUrls.isYouTube(mediaUrl)) "$base&t=${seconds}s" else "$base&t=$seconds" + } + + /** Where a shared link says to start, or null when it just points at the video. */ + fun startPositionMs(url: String): Long? { + val raw = SECONDS_PARAM.find(url)?.groupValues?.get(1) ?: return null + val match = CLOCK.find(raw) ?: return null + val (hours, minutes, seconds) = match.destructured + val total = (hours.toLongOrNull() ?: 0) * SECONDS_PER_HOUR + + (minutes.toLongOrNull() ?: 0) * SECONDS_PER_MINUTE + + (seconds.toLongOrNull() ?: 0) + return (total * MS_PER_SECOND).takeIf { it > 0 } + } + + /** + * The video's page without any timestamp already on it. + * + * Rebuilt from the id rather than edited, so a stored URL carrying a stale `t` cannot win + * over where the listener actually is. + */ + private fun canonical(mediaUrl: String): String? { + YouTubeUrls.youtubeVideoId(mediaUrl)?.let { + return "https://www.youtube.com/watch?v=$it" + } + FacebookUrls.videoId(mediaUrl)?.let { + return "https://www.facebook.com/watch?v=$it" + } + return null + } +} 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 b1a3c32..0e161e3 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.terst.listen.data.EpisodeEntity +import org.terst.listen.feed.WatchLink /** * One episode in full. @@ -128,6 +129,7 @@ private fun EpisodeDetail( OutlinedButton(onClick = actions.togglePlayed) { Text(if (episode.playedAtMs == null) "Played" else "Unplayed") } + WatchButton(WatchLink.at(episode.mediaUrl, episode.positionMs), "Watch") } // The import case: a freshly imported back catalogue is entirely unplayed, and this is 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 553f376..2b520ec 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.Graph +import org.terst.listen.data.Preferences import org.terst.listen.data.RefreshOutcome import org.terst.listen.data.RuleEntity import org.terst.listen.data.SubscriptionEntity @@ -57,8 +58,16 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { * [dripEnabled] comes from the subscription rather than being looked up here, so the list * and the drip switch above it can never disagree about which set is being shown. */ - fun episodesOf(subscriptionId: String, dripEnabled: Boolean) = - graph.episodes.observeVisible(subscriptionId, dripEnabled) + fun episodesOf(subscriptionId: String, dripEnabled: Boolean, showPlayed: Boolean) = + graph.episodes.observeVisible(subscriptionId, dripEnabled, showPlayed) + + /** Whether shows list episodes already heard. Off by default; see [Preferences]. */ + val showPlayed: StateFlow = graph.preferences.showPlayed + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), false) + + fun setShowPlayed(value: Boolean) { + viewModelScope.launch { graph.preferences.setShowPlayed(value) } + } /** * Adds a subscription from a pasted or shared URL. 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 b802825..d9dd5e6 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt @@ -102,6 +102,11 @@ fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) { 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) } @@ -115,6 +120,8 @@ fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) { } } +private const val MS_PER_SECOND = 1000L + /** * The episode's chapters, if it has any. * 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 418a0ff..cc28fe9 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -14,6 +14,7 @@ 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.PlayerConnection import org.terst.listen.playback.StartOutcome @@ -47,6 +48,14 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { private val _preparing = MutableStateFlow(null) val preparing: StateFlow = _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>(emptyList()) val chapters: StateFlow> = _chapters.asStateFlow() @@ -77,6 +86,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { 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() @@ -144,6 +154,18 @@ 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) 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 3e10466..6018e56 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -44,6 +45,8 @@ 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -69,7 +72,8 @@ fun SubscriptionScreen( val snackbar = remember { SnackbarHostState() } // Which set the show lists is the show's own business: the list and the drip switch above // it read the same flag, so they can never disagree. - val episodes by vm.episodesOf(subscriptionId, subscription?.dripEnabled == true) + val showPlayed by vm.showPlayed.collectAsState() + val episodes by vm.episodesOf(subscriptionId, subscription?.dripEnabled == true, showPlayed) .collectAsState(initial = emptyList()) DrainBanners(vm, settings, snackbar) @@ -103,10 +107,15 @@ fun SubscriptionScreen( SubscriptionBody( subscription = subscription, episodes = episodes, - settings = settings, actionsFor = { episode -> episodeActions(vm, player, nav, episode) }, modifier = Modifier.padding(padding), - ) + ) { + PlayedToggle(showPlayed, vm::setShowPlayed) + subscription?.let { show -> + HorizontalDivider(Modifier.padding(vertical = 12.dp)) + ShowSettings(show, settings) + } + } } } @@ -163,17 +172,15 @@ private fun episodeActions( private fun SubscriptionBody( subscription: SubscriptionEntity?, episodes: List, - settings: ShowSettingsViewModel, actionsFor: (EpisodeEntity) -> EpisodeActions, modifier: Modifier, + // A slot rather than more parameters: what sits above the list is a mix of view + // preferences and show settings, and the list itself does not care which. + controls: @Composable () -> Unit, ) { - val busy by settings.busy.collectAsState() - LazyColumn(modifier.fillMaxSize()) { item { SubscriptionHeader(subscription) } - subscription?.let { show -> - item { ShowSettings(show, settings, busy) } - } + item { controls() } items(episodes, key = { it.id }) { episode -> EpisodeRow( episode = episode, @@ -187,11 +194,9 @@ private fun SubscriptionBody( /** Slow drip and the skip offsets, which are settings for this show rather than for the app. */ @Composable -private fun ShowSettings( - show: SubscriptionEntity, - settings: ShowSettingsViewModel, - busy: Boolean, -) { +private fun ShowSettings(show: SubscriptionEntity, settings: ShowSettingsViewModel) { + val busy by settings.busy.collectAsState() + DripSettings( subscription = show, busy = busy, @@ -210,6 +215,30 @@ private fun ShowSettings( HorizontalDivider(Modifier.padding(top = 12.dp)) } +/** A view preference rather than a show setting, so it says so and applies everywhere. */ +@Composable +private fun PlayedToggle(showPlayed: Boolean, onShowPlayed: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + modifier = Modifier.semantics { // Distinct from the visible label: identical strings make the switch ambiguous to + // pick, for a screen reader and for the UI checker alike. + contentDescription = "Show or hide played episodes" }, + checked = showPlayed, + onCheckedChange = onShowPlayed, + ) + Column(Modifier.padding(start = 12.dp)) { + Text("Show played episodes", style = MaterialTheme.typography.titleSmall) + Text( + if (showPlayed) "Listed in every show." else "Hidden in every show.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + @Composable private fun SubscriptionHeader(subscription: SubscriptionEntity?) { if (subscription == null) return diff --git a/app/src/main/java/org/terst/listen/ui/WatchButton.kt b/app/src/main/java/org/terst/listen/ui/WatchButton.kt new file mode 100644 index 0000000..710207e --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/WatchButton.kt @@ -0,0 +1,29 @@ +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/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt index c1f7bd2..e270152 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -90,16 +90,21 @@ class FakeEpisodeDao : EpisodeDao { /** Set by [FakeQueueDao] wiring so releaseConsumed can see what is queued, as SQL does. */ var queuedIds: () -> Set = { emptySet() } - private fun visible(subscriptionId: String, dripEnabled: Boolean) = + private fun visible(subscriptionId: String, dripEnabled: Boolean, showPlayed: Boolean) = rows.filter { - it.subscriptionId == subscriptionId && (!dripEnabled || it.dripReleasedAtMs != null) + it.subscriptionId == subscriptionId && + (!dripEnabled || it.dripReleasedAtMs != null) && + (showPlayed || it.playedAtMs == null) }.sortedByDescending { it.publishedAtMs ?: 0 } - override fun observeVisible(subscriptionId: String, dripEnabled: Boolean): Flow> = - flowOf(visible(subscriptionId, dripEnabled)) + override fun observeVisible( + subscriptionId: String, + dripEnabled: Boolean, + showPlayed: Boolean, + ): Flow> = flowOf(visible(subscriptionId, dripEnabled, showPlayed)) override suspend fun getVisible(subscriptionId: String, dripEnabled: Boolean) = - visible(subscriptionId, dripEnabled) + visible(subscriptionId, dripEnabled, showPlayed = false) override suspend fun getForSubscription(subscriptionId: String) = rows.filter { it.subscriptionId == subscriptionId } diff --git a/app/src/test/java/org/terst/listen/feed/WatchLinkTest.kt b/app/src/test/java/org/terst/listen/feed/WatchLinkTest.kt new file mode 100644 index 0000000..932895a --- /dev/null +++ b/app/src/test/java/org/terst/listen/feed/WatchLinkTest.kt @@ -0,0 +1,114 @@ +package org.terst.listen.feed + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Carrying a position between Listen and the site the video came from. + * + * Both directions matter. Out: hand off to YouTube at the point you had reached, so switching + * to the picture does not mean hunting for your place. In: a link shared *from* YouTube with a + * timestamp on it was shared at that moment deliberately. + */ +class WatchLinkTest { + + @Test + fun `a youtube watch url gets a timestamp in seconds`() { + assertEquals( + "https://www.youtube.com/watch?v=abc123&t=754s", + WatchLink.at("https://www.youtube.com/watch?v=abc123", 754_000), + ) + } + + /** Rounded down: landing a second early repeats a word, a second late clips one. */ + @Test + fun `a part second rounds down`() { + assertEquals( + "https://www.youtube.com/watch?v=abc123&t=12s", + WatchLink.at("https://www.youtube.com/watch?v=abc123", 12_900), + ) + } + + @Test + fun `a short youtube link becomes a full watch link`() { + assertEquals( + "https://www.youtube.com/watch?v=abc123&t=30s", + WatchLink.at("https://youtu.be/abc123", 30_000), + ) + } + + /** A stale timestamp on the stored URL must not win over where you actually are. */ + @Test + fun `an existing timestamp is replaced, not appended`() { + assertEquals( + "https://www.youtube.com/watch?v=abc123&t=99s", + WatchLink.at("https://www.youtube.com/watch?v=abc123&t=5s", 99_000), + ) + } + + @Test + fun `facebook keeps its own url shape`() { + assertEquals( + "https://www.facebook.com/watch?v=10153231379946729&t=45", + WatchLink.at("https://www.facebook.com/watch?v=10153231379946729", 45_000), + ) + } + + @Test + fun `the start of an episode needs no timestamp`() { + assertEquals( + "https://www.youtube.com/watch?v=abc123", + WatchLink.at("https://www.youtube.com/watch?v=abc123", 0), + ) + } + + /** A podcast enclosure is not something to hand off; there is no page to open. */ + @Test + fun `an ordinary audio url has no watch link`() { + assertNull(WatchLink.at("https://cdn.example.com/episode.mp3", 30_000)) + } + + // --- reading a shared timestamp ----------------------------------------------------------- + + @Test + fun `a plain seconds parameter is read`() { + assertEquals(90_000L, WatchLink.startPositionMs("https://www.youtube.com/watch?v=a&t=90")) + } + + @Test + fun `the s suffix youtube uses is read`() { + assertEquals(90_000L, WatchLink.startPositionMs("https://www.youtube.com/watch?v=a&t=90s")) + } + + /** The share sheet emits this form for anything past a minute. */ + @Test + fun `minutes and hours are read`() { + assertEquals(90_000L, WatchLink.startPositionMs("https://youtu.be/a?t=1m30s")) + assertEquals(3_723_000L, WatchLink.startPositionMs("https://youtu.be/a?t=1h2m3s")) + } + + /** Some YouTube surfaces use `start` instead. */ + @Test + fun `the start parameter is read too`() { + assertEquals(45_000L, WatchLink.startPositionMs("https://www.youtube.com/watch?v=a&start=45")) + } + + @Test + fun `facebook timestamps are read`() { + assertEquals(20_000L, WatchLink.startPositionMs("https://www.facebook.com/watch?v=1&t=20")) + } + + @Test + fun `a link shared from the beginning has no position`() { + assertNull(WatchLink.startPositionMs("https://www.youtube.com/watch?v=abc123")) + assertNull(WatchLink.startPositionMs("https://www.youtube.com/watch?v=abc123&t=0")) + } + + /** A `t` that is not a time must not become a position of zero or a crash. */ + @Test + fun `a nonsense timestamp is ignored`() { + assertNull(WatchLink.startPositionMs("https://www.youtube.com/watch?v=a&t=soon")) + assertNull(WatchLink.startPositionMs("not a url at all")) + } +} -- cgit v1.2.3