summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle.kts3
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt19
-rw-r--r--app/src/main/java/org/terst/listen/data/Preferences.kt37
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt2
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt3
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedResolver.kt6
-rw-r--r--app/src/main/java/org/terst/listen/feed/WatchLink.kt63
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt13
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerScreen.kt7
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt22
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt57
-rw-r--r--app/src/main/java/org/terst/listen/ui/WatchButton.kt29
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt15
-rw-r--r--app/src/test/java/org/terst/listen/feed/WatchLinkTest.kt114
15 files changed, 369 insertions, 23 deletions
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<List<EpisodeEntity>>
+ fun observeVisible(
+ subscriptionId: String,
+ dripEnabled: Boolean,
+ showPlayed: Boolean,
+ ): Flow<List<EpisodeEntity>>
- /** 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<EpisodeEntity>
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<Boolean> = 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<Boolean> = 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<String?>(null)
val preparing: StateFlow<String?> = _preparing.asStateFlow()
+ /**
+ * The playing episode, so the watch link can be built without suspending.
+ *
+ * Declared here rather than beside loadContext: the collector in init emits the current
+ * value synchronously, so a property declared after init is still null when it first runs.
+ */
+ private var currentEpisode: EpisodeEntity? = null
+
/** Chapters for whatever is playing, empty when the show publishes none. */
private val _chapters = MutableStateFlow<List<Chapter>>(emptyList())
val chapters: StateFlow<List<Chapter>> = _chapters.asStateFlow()
@@ -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<EpisodeEntity>,
- 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<String> = { 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<List<EpisodeEntity>> =
- flowOf(visible(subscriptionId, dripEnabled))
+ override fun observeVisible(
+ subscriptionId: String,
+ dripEnabled: Boolean,
+ showPlayed: Boolean,
+ ): Flow<List<EpisodeEntity>> = 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"))
+ }
+}