diff options
Diffstat (limited to 'app')
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")) + } +} |
