summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt38
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt20
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt10
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt18
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt3
-rw-r--r--app/src/main/java/org/terst/listen/feed/ChapterFetcher.kt57
-rw-r--r--app/src/main/java/org/terst/listen/feed/ChapterParser.kt62
-rw-r--r--app/src/main/java/org/terst/listen/playback/AdChapters.kt31
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt40
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt20
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt68
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt5
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerScreen.kt56
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt67
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt112
-rw-r--r--app/src/main/java/org/terst/listen/ui/SkipSettings.kt96
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt132
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt12
-rw-r--r--app/src/test/java/org/terst/listen/feed/ChapterFetcherTest.kt104
-rw-r--r--app/src/test/java/org/terst/listen/feed/ChapterParserTest.kt98
-rw-r--r--app/src/test/java/org/terst/listen/playback/AdChaptersTest.kt97
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt88
22 files changed, 1105 insertions, 129 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
index 6c40126..0eeab50 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -110,4 +110,42 @@ class MigrationTest {
assertEquals(true, cursor.isNull(1))
}
}
+
+ /** The whole chain, which is the path a phone that skipped two builds actually takes. */
+ @Test
+ fun migrates1To4KeepingEpisodesAndDefaultingChapterSkip() {
+ helper.createDatabase(name, 1).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 47000, 0)"""
+ )
+ db.execSQL(
+ """INSERT INTO episodes
+ (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs,
+ positionMs, downloadState)
+ VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000,
+ 555555, 'NONE')"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(
+ name, 4, true,
+ AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4,
+ )
+
+ // Ad-chapter skipping must arrive off. It is the one part of the feature that can remove
+ // audio nobody has heard, and defaulting it on would do that to every show at once.
+ db.query("SELECT skipIntroMs, skipAdChapters FROM subscriptions WHERE id = 's1'").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals("a manual offset set before the upgrade must survive", 47000L, cursor.getLong(0))
+ assertEquals(0, cursor.getInt(1))
+ }
+
+ db.query("SELECT positionMs, chaptersJson FROM episodes WHERE id = 'e1'").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals(555555L, cursor.getLong(0))
+ assertEquals(true, cursor.isNull(1))
+ }
+ }
}
diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
index f779b06..7897678 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -25,7 +25,7 @@ class Converters {
QueueEntity::class,
RuleEntity::class,
],
- version = 3,
+ version = 4,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -82,6 +82,22 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Chapters: the fetched Podcasting 2.0 file per episode, and the per-show opt-in to
+ * skipping the breaks it marks.
+ *
+ * The chapter list is a JSON blob on the episode rather than a table of its own. It is
+ * only ever read whole, for one episode, and a table would buy joins nothing needs.
+ */
+ val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `chaptersJson` TEXT")
+ db.execSQL(
+ "ALTER TABLE `subscriptions` ADD COLUMN `skipAdChapters` INTEGER NOT NULL DEFAULT 0"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -90,7 +106,7 @@ abstract class AppDatabase : RoomDatabase() {
Room.databaseBuilder(context, AppDatabase::class.java, "listen.db")
// Listed one by one rather than spread from an array: every migration here is a
// deliberate addition, and the list is the record of them.
- .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
// No fallbackToDestructiveMigration. A listener's playback positions and play
// history are not reproducible from anywhere, so a missing migration must fail
// loudly rather than silently wipe the library.
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 6b2d334..608cf77 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -59,6 +59,13 @@ interface SubscriptionDao {
@Query("UPDATE subscriptions SET playbackSpeed = :speed WHERE id = :id")
suspend fun updateSpeed(id: String, speed: Float)
+ @Query("UPDATE subscriptions SET skipAdChapters = :enabled WHERE id = :id")
+ suspend fun setSkipAdChapters(id: String, enabled: Boolean)
+
+ /** Tier 2 of the skip feature: how much of the top and tail of every episode to pass over. */
+ @Query("UPDATE subscriptions SET skipIntroMs = :introMs, skipOutroMs = :outroMs WHERE id = :id")
+ suspend fun updateSkips(id: String, introMs: Long, outroMs: Long)
+
@Query("SELECT * FROM subscriptions WHERE dripEnabled = 1")
suspend fun dripEnabled(): List<SubscriptionEntity>
@@ -188,6 +195,9 @@ interface EpisodeDao {
)
suspend fun markPlayedUpTo(subscriptionId: String, publishedAtMs: Long, atMs: Long)
+ @Query("UPDATE episodes SET chaptersJson = :json WHERE id = :id")
+ suspend fun setChapters(id: String, json: String?)
+
/** Records which auto-queue rule claimed an episode, why, and any speed it asked for. */
@Query(
"""UPDATE episodes SET appliedRuleId = :ruleId, appliedRuleReason = :reason,
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
index f366aab..359a626 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -62,6 +62,15 @@ data class SubscriptionEntity(
@ColumnInfo(defaultValue = "1") val dripOldestFirst: Boolean = true,
/** When the last batch was released. Null means drip has never paid out for this show. */
val dripLastReleaseAtMs: Long? = null,
+
+ /**
+ * Whether to pass over chapters the publisher marked `toc: false`.
+ *
+ * Off by default and opt-in per show. Acting on someone else's metadata to remove audio a
+ * listener has not heard is the one part of this feature that can eat an episode, so it is
+ * never on unless asked for.
+ */
+ @ColumnInfo(defaultValue = "0") val skipAdChapters: Boolean = false,
)
/** One a day, the cadence a drip subscription is for. */
@@ -123,6 +132,15 @@ data class EpisodeEntity(
val appliedRuleReason: String? = null,
/** A speed set for this episode alone by a rule, taking precedence over the show's. */
val speedOverride: Float? = null,
+
+ /**
+ * The fetched Podcasting 2.0 chapter file, verbatim, or null if there is none yet.
+ *
+ * Stored as the raw document rather than parsed rows: it is only ever read whole, for one
+ * episode, and keeping the original means a parser improvement re-reads real data rather
+ * than whatever an earlier version managed to understand.
+ */
+ val chaptersJson: String? = null,
)
/** What an auto-queue rule does to an episode that matches it. */
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 4f39c13..773aa96 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -6,6 +6,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import okhttp3.OkHttpClient
import org.terst.listen.download.ChaquopyYouTubeSource
import org.terst.listen.download.EpisodeDownloader
+import org.terst.listen.feed.ChapterFetcher
import org.terst.listen.feed.FeedFetcher
import org.terst.listen.feed.FeedResolver
import org.terst.listen.queue.DripReleaser
@@ -56,6 +57,8 @@ class AppGraph(private val context: Context) {
val drip: DripReleaser by lazy { DripReleaser(subscriptions, episodes, queue) }
+ val chapters: ChapterFetcher by lazy { ChapterFetcher(client, episodes) }
+
val repository: SubscriptionRepository by lazy {
SubscriptionRepository(
subscriptions,
diff --git a/app/src/main/java/org/terst/listen/feed/ChapterFetcher.kt b/app/src/main/java/org/terst/listen/feed/ChapterFetcher.kt
new file mode 100644
index 0000000..018cec4
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/ChapterFetcher.kt
@@ -0,0 +1,57 @@
+package org.terst.listen.feed
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.data.EpisodeEntity
+import java.io.IOException
+
+/**
+ * Fetches an episode's Podcasting 2.0 chapter file, once, and caches it on the episode.
+ *
+ * Failure is silent by design, unlike a feed refresh. A show with no chapters is the normal
+ * case, not a fault, and a banner every time a publisher's chapter URL 404s would be noise on
+ * something the listener never asked for.
+ */
+class ChapterFetcher(
+ private val client: OkHttpClient,
+ private val episodes: EpisodeDao,
+) {
+
+ /** Returns the chapters for an episode, fetching them the first time they are wanted. */
+ suspend fun chapters(episode: EpisodeEntity): List<Chapter> {
+ episode.chaptersJson?.let { return ChapterParser.parse(it) }
+ val url = episode.chaptersUrl ?: return emptyList()
+
+ // The reason a chapter file could not be fetched is deliberately not surfaced: a show
+ // with no usable chapters is the normal case, not a fault, and a banner every time a
+ // publisher's chapter URL 404s would be noise on something nobody asked for.
+ val body = fetch(url).getOrNull() ?: return emptyList()
+ val parsed = ChapterParser.parse(body)
+ // Cached even when it parses to nothing, so an episode whose chapter file is junk is not
+ // re-fetched on every play.
+ episodes.setChapters(episode.id, body)
+ return parsed
+ }
+
+ private suspend fun fetch(url: String): Result<String> = withContext(Dispatchers.IO) {
+ val request = Request.Builder().url(url).build()
+ try {
+ client.newCall(request).execute().use { response ->
+ val body = response.body?.string()
+ when {
+ !response.isSuccessful -> Result.failure(IOException("HTTP ${response.code}"))
+ body == null -> Result.failure(IOException("empty chapter file"))
+ else -> Result.success(body)
+ }
+ }
+ } catch (e: IOException) {
+ Result.failure(e)
+ } catch (e: IllegalArgumentException) {
+ // A chapter URL that is not a URL at all; the feed said so, not us.
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/feed/ChapterParser.kt b/app/src/main/java/org/terst/listen/feed/ChapterParser.kt
new file mode 100644
index 0000000..e695436
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/ChapterParser.kt
@@ -0,0 +1,62 @@
+package org.terst.listen.feed
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+/** One chapter of an episode, as a listener would navigate it. */
+data class Chapter(
+ val startMs: Long,
+ val endMs: Long? = null,
+ val title: String? = null,
+ /**
+ * Whether the publisher counts this as part of the episode.
+ *
+ * `toc: false` is how Podcasting 2.0 marks a section that is not really content -- in
+ * practice, almost always an ad read.
+ */
+ val inToc: Boolean = true,
+)
+
+/**
+ * Parses a Podcasting 2.0 chapters file.
+ *
+ * Tier 1 of the skip feature, and the only exact one: where a show marks its own breaks,
+ * detection is just reading what it said. Everything here degrades to an empty list rather than
+ * throwing, because the input is a JSON file fetched from a URL in a stranger's feed and a
+ * malformed one must not take playback down with it.
+ */
+object ChapterParser {
+
+ private const val MS_PER_SECOND = 1000.0
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun parse(raw: String): List<Chapter> {
+ val dto = runCatching { json.decodeFromString<Document>(raw) }.getOrNull() ?: return emptyList()
+ return dto.chapters.orEmpty()
+ // A chapter with no start time has nowhere to sit on the timeline.
+ .mapNotNull { chapter ->
+ chapter.startTime?.let {
+ Chapter(
+ startMs = (it * MS_PER_SECOND).toLong(),
+ endMs = chapter.endTime?.let { end -> (end * MS_PER_SECOND).toLong() },
+ title = chapter.title?.takeIf(String::isNotBlank),
+ inToc = chapter.toc ?: true,
+ )
+ }
+ }
+ // Publishers do emit them out of order, and everything downstream assumes otherwise.
+ .sortedBy { it.startMs }
+ }
+
+ @Serializable
+ private data class Document(val chapters: List<Entry>? = null)
+
+ @Serializable
+ private data class Entry(
+ val startTime: Double? = null,
+ val endTime: Double? = null,
+ val title: String? = null,
+ val toc: Boolean? = null,
+ )
+}
diff --git a/app/src/main/java/org/terst/listen/playback/AdChapters.kt b/app/src/main/java/org/terst/listen/playback/AdChapters.kt
new file mode 100644
index 0000000..bf1a2b1
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/AdChapters.kt
@@ -0,0 +1,31 @@
+package org.terst.listen.playback
+
+import org.terst.listen.feed.Chapter
+
+/**
+ * Whether the playhead is sitting in a section the publisher marked as not-content, and where
+ * to jump to if it is.
+ *
+ * Conservative by design. The design plan's warning stands: a skip feature that silently eats
+ * content is worse than none. So the only signal trusted here is `toc: false` -- the publisher
+ * saying outright that a section is not part of the episode -- and never the chapter title. A
+ * show discussing its own sponsorship deal has a chapter called "Sponsors" that is the actual
+ * programme, and guessing from titles is how a skip feature starts deleting episodes.
+ */
+object AdChapters {
+
+ /** Where to jump to if [positionMs] is inside a marked break, or null to carry on playing. */
+ fun skipTarget(chapters: List<Chapter>, positionMs: Long, durationMs: Long?): Long? {
+ val index = chapters.indexOfLast { it.startMs <= positionMs }
+ if (index < 0) return null
+
+ val chapter = chapters[index]
+ if (chapter.inToc) return null
+
+ // Its end, the next chapter's start, or the end of the episode -- in that order. With
+ // none of the three there is nowhere to jump to, so the break plays rather than the
+ // playhead being thrown somewhere arbitrary.
+ val end = chapter.endMs ?: chapters.getOrNull(index + 1)?.startMs ?: durationMs ?: return null
+ return end.takeIf { it > positionMs }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt b/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt
index ab34c28..bdec56d 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackProgress.kt
@@ -22,30 +22,56 @@ class PlaybackProgress(
private val resumeFloorMs: Long = 5_000,
) {
- fun isComplete(positionMs: Long, durationMs: Long?): Boolean {
+ /**
+ * [skipOutroMs] moves the finish line earlier, for a show whose last minutes are always the
+ * same credits. An offset that would swallow the whole episode is ignored rather than
+ * obeyed: someone typing minutes where seconds were meant should not mark a show played the
+ * moment it starts.
+ */
+ fun isComplete(positionMs: Long, durationMs: Long?, skipOutroMs: Long = 0): Boolean {
if (durationMs == null || durationMs <= 0) return false
- val remaining = durationMs - positionMs
+ val end = effectiveEnd(durationMs, skipOutroMs)
+ val remaining = end - positionMs
// Effectively at the end.
if (remaining <= minRemainingMs) return true
// Otherwise past the threshold, but only if what's left is plausibly credits rather
// than content. A percentage on its own scales wrongly with episode length.
- return positionMs >= durationMs * completionThreshold && remaining <= maxCreditsMs
+ return positionMs >= end * completionThreshold && remaining <= maxCreditsMs
}
/**
+ * Where an episode ends as far as this app is concerned.
+ *
+ * An outro offset at or past the duration is nonsense and is dropped; anything shorter
+ * brings the end forward.
+ */
+ private fun effectiveEnd(durationMs: Long, skipOutroMs: Long): Long =
+ if (skipOutroMs <= 0 || skipOutroMs >= durationMs) durationMs else durationMs - skipOutroMs
+
+ /**
* Where to start playing.
*
* A stored position past the end means the media changed under us -- a re-encode, or a feed
* whose declared duration was wrong. Restarting beats seeking past the end and stalling.
+ *
+ * [skipIntroMs] applies only to a start from the beginning, never to a resume: coming back
+ * to something half-heard must land where it was left, not at the show's theme-tune offset.
+ * An offset longer than the episode is dropped, for the same reason the outro one is.
*/
- fun resumePosition(storedMs: Long, durationMs: Long?): Long = when {
- storedMs < resumeFloorMs -> 0
+ fun resumePosition(storedMs: Long, durationMs: Long?, skipIntroMs: Long = 0): Long = when {
+ storedMs < resumeFloorMs -> start(durationMs, skipIntroMs)
durationMs == null -> storedMs
- storedMs >= durationMs -> 0
- isComplete(storedMs, durationMs) -> 0
+ storedMs >= durationMs -> start(durationMs, skipIntroMs)
+ isComplete(storedMs, durationMs) -> start(durationMs, skipIntroMs)
else -> storedMs
}
+ private fun start(durationMs: Long?, skipIntroMs: Long): Long = when {
+ skipIntroMs <= 0 -> 0
+ durationMs != null && skipIntroMs >= durationMs -> 0
+ else -> skipIntroMs
+ }
+
/**
* Whether the position has moved enough to write.
*
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 d1e788e..e44ff35 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -36,6 +36,9 @@ class PlayerConnection(private val context: Context) {
private var controller: MediaController? = null
+ /** The resume rules. A field rather than a parameter: nothing ever passed a different one. */
+ private val progress = PlaybackProgress()
+
/**
* Fired when an episode plays to its end, with that episode's id.
*
@@ -87,22 +90,27 @@ class PlayerConnection(private val context: Context) {
}
/**
- * Starts an episode, seeking to where the listener got to.
+ * Starts an episode where the listener should hear it.
+ *
+ * The decision belongs to PlaybackProgress, not here: a stored position past the end has to
+ * restart rather than seek past the end and stall, and a fresh start honours the show's
+ * intro offset.
*
- * The resume decision belongs to PlaybackProgress, not here: a stored position past the end
- * has to restart rather than seek past the end and stall.
+ * The start position goes to `setMediaItem`, not to a `seekTo` after `prepare()`. Seeking
+ * afterwards is silently dropped across the controller-to-service boundary -- verified on a
+ * device: an episode set to start sixty seconds in began at zero, and so, all along, did
+ * every resume. Nothing failed and nothing logged; playback simply started at the top.
*/
fun play(
item: MediaItem,
storedPositionMs: Long,
durationMs: Long?,
speed: Float,
- progress: PlaybackProgress = PlaybackProgress(),
+ skipIntroMs: Long = 0,
) {
val player = controller ?: return
- player.setMediaItem(item)
+ player.setMediaItem(item, progress.resumePosition(storedPositionMs, durationMs, skipIntroMs))
player.prepare()
- player.seekTo(progress.resumePosition(storedPositionMs, durationMs))
player.applySpeed(speed, speed)
player.play()
publish()
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 23ba532..553f376 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import org.terst.listen.data.ArchiveOutcome
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
import org.terst.listen.data.RefreshOutcome
@@ -23,20 +22,6 @@ import org.terst.listen.feed.YouTubeUrls
/** Transient message for the UI. Errors are surfaced, never only logged. */
data class Banner(val text: String, val isError: Boolean = false)
-/**
- * An archive walk, reported in full.
- *
- * A partial walk is flagged as an error even though episodes did arrive: a chain that stopped
- * early means the rest of the catalogue is missing, and a plain success message would leave the
- * show looking permanently short with no indication why.
- */
-private fun ArchiveOutcome.Fetched.toBanner(): Banner {
- val found = "$newEpisodes older episode(s) from $pages page(s)"
- return stoppedEarly
- ?.let { Banner("$found, then stopped: $it", isError = true) }
- ?: Banner(found)
-}
-
class ListenViewModel(app: Application) : AndroidViewModel(app) {
private val graph = Graph.get(app)
@@ -149,59 +134,6 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
}
}
- // --- slow drip -------------------------------------------------------------------------
-
- /**
- * Turns slow-drip on for a show, or adjusts a running one, and pays out anything owed.
- *
- * Releasing straight away is deliberate: flipping the switch and seeing nothing happen for
- * a day reads as the feature not working.
- */
- fun enableDrip(subscriptionId: String, intervalMs: Long, count: Int, oldestFirst: Boolean) {
- viewModelScope.launch {
- graph.drip.enable(subscriptionId, intervalMs, count, oldestFirst)
- val released = graph.subscriptions.getById(subscriptionId)?.let { graph.drip.release(it) } ?: 0
- _banner.value = if (released > 0) {
- Banner("Dripping — $released episode(s) queued")
- } else {
- Banner("Dripping — nothing to release yet")
- }
- }
- }
-
- fun disableDrip(subscriptionId: String) {
- viewModelScope.launch {
- graph.drip.disable(subscriptionId)
- _banner.value = Banner("Drip off — the whole catalogue is back")
- }
- }
-
- /**
- * Pulls a show's back catalogue by walking its RFC 5005 archive pages.
- *
- * Reported in detail rather than as a bare success: plenty of publishers do not implement
- * paging at all, and "nothing happened" needs to be distinguishable from "it failed".
- */
- fun fetchArchive(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.fetchArchive(subscription)
- _refreshing.value = false
- _banner.value = when (outcome) {
- ArchiveOutcome.NoArchive ->
- Banner("This feed publishes no archive pages, so there is nothing older to fetch")
- is ArchiveOutcome.Failed -> Banner(outcome.message, isError = true)
- is ArchiveOutcome.Fetched -> outcome.toBanner()
- }
- }
- }
-
fun unsubscribe(id: String) {
viewModelScope.launch { graph.subscriptions.delete(id) }
}
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 9632b02..a63082b 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -20,10 +20,11 @@ class MainActivity : ComponentActivity() {
private val playerViewModel: PlayerViewModel by viewModels()
private val rulesViewModel: RulesViewModel by viewModels()
private val opmlViewModel: OpmlViewModel by viewModels()
+ private val showSettingsViewModel: ShowSettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContent { ListenTheme { ListenApp(viewModel, playerViewModel, rulesViewModel, opmlViewModel) } }
+ setContent { ListenTheme { ListenApp(viewModel, playerViewModel, rulesViewModel, opmlViewModel, showSettingsViewModel) } }
}
}
@@ -46,6 +47,7 @@ private fun ListenApp(
player: PlayerViewModel,
rulesViewModel: RulesViewModel,
opmlViewModel: OpmlViewModel,
+ showSettings: ShowSettingsViewModel,
) {
val nav = rememberNavController()
@@ -88,6 +90,7 @@ private fun ListenApp(
SubscriptionScreen(
vm = viewModel,
player = player,
+ settings = showSettings,
subscriptionId = id,
nav = SubscriptionNav(
openEpisode = { nav.navigate("episode/$it") },
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 1af21c5..e012834 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
@@ -26,18 +26,23 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import org.terst.listen.feed.Chapter
/** Offered speeds. Beyond 2x speech becomes hard to follow, so the list stops there. */
private val SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
@@ -47,6 +52,7 @@ private val SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) {
val nowPlaying by player.nowPlaying.collectAsState()
val show by player.currentShow.collectAsState()
+ val chapters by player.chapters.collectAsState()
Scaffold(
topBar = {
@@ -93,6 +99,56 @@ fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) {
if (show != null) {
SpeedRow(current = show?.playbackSpeed ?: nowPlaying.speed, onSelect = player::setShowSpeed)
}
+
+ ChapterList(
+ chapters = chapters,
+ positionMs = nowPlaying.positionMs,
+ onSeek = player::seekTo,
+ )
+ }
+ }
+}
+
+/**
+ * The episode's chapters, if it has any.
+ *
+ * Renders nothing at all when it does not, which is most shows -- an empty "Chapters" heading on
+ * every episode would be a permanent reminder of a feature that rarely applies.
+ */
+@Composable
+private fun ColumnScope.ChapterList(chapters: List<Chapter>, positionMs: Long, onSeek: (Long) -> Unit) {
+ if (chapters.isEmpty()) return
+
+ val currentIndex = chapters.indexOfLast { it.startMs <= positionMs }
+
+ Column(Modifier.fillMaxWidth().weight(1f, fill = false).verticalScroll(rememberScrollState())) {
+ Text("Chapters", style = MaterialTheme.typography.titleSmall)
+ chapters.forEachIndexed { index, chapter ->
+ Row(
+ Modifier.fillMaxWidth().clickable { onSeek(chapter.startMs) }.padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ formatClock(chapter.startMs),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(end = 12.dp),
+ )
+ Text(
+ chapter.title ?: "Chapter ${index + 1}",
+ style = if (index == currentIndex) {
+ MaterialTheme.typography.bodyMedium
+ } else {
+ MaterialTheme.typography.bodySmall
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ // Marked so a skipped section is explained rather than just vanishing.
+ if (!chapter.inToc) {
+ Text("break", style = MaterialTheme.typography.bodySmall)
+ }
+ }
}
}
}
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 106bae4..c91a52f 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -11,7 +11,10 @@ import kotlinx.coroutines.launch
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.playback.AdChapters
import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlaybackProgress
import org.terst.listen.playback.PlaybackSpeed
import org.terst.listen.playback.PlayerConnection
import org.terst.listen.playback.SleepTimer
@@ -42,6 +45,15 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
}
val nowPlaying: StateFlow<NowPlaying> = connection.nowPlaying
+ private val progress = PlaybackProgress()
+
+ /** The episode whose outro has already been acted on, so the tick fires once per episode. */
+ private var outroHandledFor: String? = 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()
+
/** The show behind whatever is currently playing, for the player screen's speed control. */
private val _currentShow = MutableStateFlow<SubscriptionEntity?>(null)
val currentShow: StateFlow<SubscriptionEntity?> = _currentShow.asStateFlow()
@@ -52,10 +64,48 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
while (true) {
delay(PROGRESS_TICK_MS)
connection.publish()
+ finishIfPastTheOutro()
+ skipPastAnAdChapter()
}
}
}
+ /**
+ * Ends an episode early when its show has an outro offset set.
+ *
+ * Media3 reports STATE_ENDED at the real end of the file and nowhere else, so an offset that
+ * moves the finish line needs the tick to notice it. Guarded by episode id because the tick
+ * keeps running after the queue advances, and firing twice would mark the next episode
+ * played before a note of it had been played.
+ */
+ private fun finishIfPastTheOutro() {
+ val outroMs = _currentShow.value?.skipOutroMs ?: return
+ if (outroMs <= 0) return
+ val playing = connection.nowPlaying.value
+ val episodeId = playing.episodeId ?: return
+ if (episodeId == outroHandledFor) return
+ if (!progress.isComplete(playing.positionMs, playing.durationMs, outroMs)) return
+
+ outroHandledFor = episodeId
+ onFinished(episodeId)
+ }
+
+ /**
+ * Jumps over a chapter the publisher marked as not-content, when the show opts in.
+ *
+ * A tick means up to a second of the break plays before the jump. Living with that beats
+ * pre-empting it: seeking early on a chapter boundary that turns out to be wrong removes
+ * audio the listener wanted, and this feature only earns its place by never doing that.
+ */
+ private fun skipPastAnAdChapter() {
+ if (_currentShow.value?.skipAdChapters != true) return
+ val playing = connection.nowPlaying.value
+ if (!playing.isPlaying) return
+ val target = AdChapters.skipTarget(_chapters.value, playing.positionMs, playing.durationMs)
+ ?: return
+ connection.seekTo(target)
+ }
+
override fun onCleared() {
connection.release()
super.onCleared()
@@ -72,12 +122,18 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
val show = graph.subscriptions.getById(episode.subscriptionId)
_currentShow.value = show
val speed = PlaybackSpeed.forEpisode(episode.speedOverride, show?.playbackSpeed)
+ outroHandledFor = null
+ _chapters.value = emptyList()
connection.play(
item = episode.toMediaItem(showTitle = show?.title, author = show?.author, showImageUrl = show?.imageUrl),
storedPositionMs = episode.positionMs,
durationMs = episode.durationMs,
speed = speed,
+ skipIntroMs = show?.skipIntroMs ?: 0,
)
+ // After playback has started, never before: a chapter file is a second network
+ // round trip and nothing should wait on it to hear the episode.
+ _chapters.value = graph.chapters.chapters(episode)
}
}
@@ -91,7 +147,16 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
graph.episodes.markPlayed(episodeId, System.currentTimeMillis())
val nextId = graph.queue.advance()
- val next = nextId?.let { graph.episodes.getById(it) } ?: return@launch
+ val next = nextId?.let { graph.episodes.getById(it) }
+ if (next == null) {
+ // Stopping matters now that an episode can finish before its file does. Left
+ // running, an outro offset marked the episode played and then played the
+ // credits anyway, which is the exact thing the offset exists to avoid. When
+ // there is a next episode, starting it replaces the media item and has the
+ // same effect.
+ connection.pause()
+ return@launch
+ }
play(next)
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
new file mode 100644
index 0000000..1e972f9
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
@@ -0,0 +1,112 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.listen.data.ArchiveOutcome
+import org.terst.listen.data.Graph
+
+/**
+ * An archive walk, reported in full.
+ *
+ * A partial walk is flagged as an error even though episodes did arrive: a chain that stopped
+ * early means the rest of the catalogue is missing, and a plain success message would leave the
+ * show looking permanently short with no indication why.
+ */
+private fun ArchiveOutcome.Fetched.toBanner(): Banner {
+ val found = "$newEpisodes older episode(s) from $pages page(s)"
+ return stoppedEarly
+ ?.let { Banner("$found, then stopped: $it", isError = true) }
+ ?: Banner(found)
+}
+
+/**
+ * The per-show settings: slow drip, the archive fetch, and the skip offsets.
+ *
+ * Split out of [ListenViewModel], which had become the entry point for the library, the queue,
+ * episode actions and these as well. They belong together and nowhere else: one screen uses
+ * them, they all act on a single subscription, and none of them touches the library as a whole.
+ *
+ * It carries its own busy flag rather than sharing the library's refreshing one. Fetching an
+ * archive and refreshing a feed are different operations of very different lengths, and one
+ * spinner covering both made the top bar claim a refresh was running when it was not.
+ */
+class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val graph = Graph.get(app)
+
+ private val _banner = MutableStateFlow<Banner?>(null)
+ val banner: StateFlow<Banner?> = _banner.asStateFlow()
+
+ private val _busy = MutableStateFlow(false)
+ val busy: StateFlow<Boolean> = _busy.asStateFlow()
+
+ fun dismissBanner() {
+ _banner.value = null
+ }
+
+ /**
+ * Turns slow-drip on for a show, or adjusts a running one, and pays out anything owed.
+ *
+ * Releasing straight away is deliberate: flipping the switch and seeing nothing happen for
+ * a day reads as the feature not working.
+ */
+ fun enableDrip(subscriptionId: String, intervalMs: Long, count: Int, oldestFirst: Boolean) {
+ viewModelScope.launch {
+ graph.drip.enable(subscriptionId, intervalMs, count, oldestFirst)
+ val released = graph.subscriptions.getById(subscriptionId)?.let { graph.drip.release(it) } ?: 0
+ _banner.value = if (released > 0) {
+ Banner("Dripping — $released episode(s) queued")
+ } else {
+ Banner("Dripping — nothing to release yet")
+ }
+ }
+ }
+
+ fun disableDrip(subscriptionId: String) {
+ viewModelScope.launch {
+ graph.drip.disable(subscriptionId)
+ _banner.value = Banner("Drip off — the whole catalogue is back")
+ }
+ }
+
+ /**
+ * Pulls a show's back catalogue by walking its RFC 5005 archive pages.
+ *
+ * Reported in detail rather than as a bare success: plenty of publishers do not implement
+ * paging at all, and "nothing happened" needs to be distinguishable from "it failed".
+ */
+ fun fetchArchive(subscriptionId: String) {
+ if (_busy.value) return
+ _busy.value = true
+ viewModelScope.launch {
+ val subscription = graph.subscriptions.getById(subscriptionId)
+ if (subscription == null) {
+ _busy.value = false
+ return@launch
+ }
+ val outcome = graph.repository.fetchArchive(subscription)
+ _busy.value = false
+ _banner.value = when (outcome) {
+ ArchiveOutcome.NoArchive ->
+ Banner("This feed publishes no archive pages, so there is nothing older to fetch")
+ is ArchiveOutcome.Failed -> Banner(outcome.message, isError = true)
+ is ArchiveOutcome.Fetched -> outcome.toBanner()
+ }
+ }
+ }
+
+ /** Tier 2 skip offsets for one show; see SkipSettings for what they mean. */
+ fun setSkips(subscriptionId: String, introMs: Long, outroMs: Long) {
+ viewModelScope.launch { graph.subscriptions.updateSkips(subscriptionId, introMs, outroMs) }
+ }
+
+ /** Tier 1 skip: act on the breaks a show marks in its own chapter file. Opt-in per show. */
+ fun setSkipAdChapters(subscriptionId: String, enabled: Boolean) {
+ viewModelScope.launch { graph.subscriptions.setSkipAdChapters(subscriptionId, enabled) }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt
new file mode 100644
index 0000000..7c6146c
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt
@@ -0,0 +1,96 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.SubscriptionEntity
+
+private const val MS_PER_SECOND = 1000L
+
+/**
+ * Per-show intro and outro offsets.
+ *
+ * The cheap tier of the skip feature, and the one that always works: a show whose theme runs
+ * forty-seven seconds gets forty-seven seconds skipped, with no detection involved. It is also
+ * the override for when detection eventually arrives and gets a show wrong.
+ */
+@Composable
+fun SkipSettings(
+ subscription: SubscriptionEntity,
+ onChange: (introMs: Long, outroMs: Long) -> Unit,
+ onSkipAdChapters: (Boolean) -> Unit,
+) {
+ Column(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text("Skip", style = MaterialTheme.typography.titleSmall)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ SecondsField(
+ label = "Intro (sec)",
+ valueMs = subscription.skipIntroMs,
+ modifier = Modifier.weight(1f),
+ ) { onChange(it, subscription.skipOutroMs) }
+ SecondsField(
+ label = "Outro (sec)",
+ valueMs = subscription.skipOutroMs,
+ modifier = Modifier.weight(1f),
+ ) { onChange(subscription.skipIntroMs, it) }
+ }
+ Text(
+ "Starts each new episode past the intro and treats it as finished before the outro. " +
+ "Resuming something part-heard still lands where you left it.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(
+ modifier = Modifier.semantics { contentDescription = "Skip marked ad chapters" },
+ checked = subscription.skipAdChapters,
+ onCheckedChange = onSkipAdChapters,
+ )
+ Text("Skip marked breaks", Modifier.padding(start = 12.dp))
+ }
+ Text(
+ "Only where the show publishes chapters and marks a section as not part of the " +
+ "episode. Nothing is guessed from chapter titles, so a show discussing its own " +
+ "sponsors keeps playing.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
+private fun SecondsField(
+ label: String,
+ valueMs: Long,
+ modifier: Modifier,
+ onChange: (Long) -> Unit,
+) {
+ OutlinedTextField(
+ // Blank rather than "0" for an unset offset: a zero in the box reads as a value someone
+ // chose, and invites wondering what it does.
+ value = if (valueMs > 0) (valueMs / MS_PER_SECOND).toString() else "",
+ onValueChange = { entered ->
+ onChange(entered.filter(Char::isDigit).toLongOrNull()?.times(MS_PER_SECOND) ?: 0)
+ },
+ label = { Text(label) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = modifier,
+ )
+}
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 42e9b1d..3e10466 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -58,25 +58,21 @@ import java.util.concurrent.TimeUnit
fun SubscriptionScreen(
vm: ListenViewModel,
player: PlayerViewModel,
+ settings: ShowSettingsViewModel,
subscriptionId: String,
nav: SubscriptionNav,
) {
var confirming by remember { mutableStateOf(false) }
val refreshing by vm.refreshing.collectAsState()
- val banner by vm.banner.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId }
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)
+ .collectAsState(initial = emptyList())
- // This screen had no snackbar until slow-drip needed one, so a refresh started from here
- // reported nothing. Fetching an archive is slower still and can half-succeed, which is
- // exactly the case that must not be silent.
- LaunchedEffect(banner) {
- banner?.let {
- snackbar.showSnackbar(it.text)
- vm.dismissBanner()
- }
- }
+ DrainBanners(vm, settings, snackbar)
Scaffold(
topBar = {
@@ -105,67 +101,115 @@ fun SubscriptionScreen(
)
SubscriptionBody(
- vm = vm,
- player = player,
subscription = subscription,
- nav = nav,
+ episodes = episodes,
+ settings = settings,
+ actionsFor = { episode -> episodeActions(vm, player, nav, episode) },
modifier = Modifier.padding(padding),
)
}
}
-/** The scrolling part: the show, its drip settings, and the episodes it currently lists. */
+/**
+ * Drains both view models' messages into the one snackbar.
+ *
+ * This screen had no snackbar at all until slow-drip needed one, so a refresh started from here
+ * reported nothing. Fetching an archive is slower still and can half-succeed, which is exactly
+ * the case that must not be silent -- and the per-show settings report through their own view
+ * model, so both have to be drained or one of them goes unseen.
+ */
@Composable
-private fun SubscriptionBody(
+private fun DrainBanners(
+ vm: ListenViewModel,
+ settings: ShowSettingsViewModel,
+ snackbar: SnackbarHostState,
+) {
+ val banner by vm.banner.collectAsState()
+ val settingsBanner by settings.banner.collectAsState()
+
+ LaunchedEffect(banner) {
+ banner?.let {
+ snackbar.showSnackbar(it.text)
+ vm.dismissBanner()
+ }
+ }
+ LaunchedEffect(settingsBanner) {
+ settingsBanner?.let {
+ snackbar.showSnackbar(it.text)
+ settings.dismissBanner()
+ }
+ }
+}
+
+/** Everything one episode row can do, gathered so the list does not need three view models. */
+private fun episodeActions(
vm: ListenViewModel,
player: PlayerViewModel,
- subscription: SubscriptionEntity?,
nav: SubscriptionNav,
+ episode: EpisodeEntity,
+) = EpisodeActions(
+ play = { player.play(episode) },
+ enqueue = { vm.enqueue(episode.id) },
+ playNext = { vm.playNext(episode.id) },
+ download = { vm.download(episode) },
+ openDetails = { nav.openEpisode(episode.id) },
+ togglePlayed = {
+ if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
+ },
+)
+
+/** The scrolling part: the show, its per-show settings, and the episodes it currently lists. */
+@Composable
+private fun SubscriptionBody(
+ subscription: SubscriptionEntity?,
+ episodes: List<EpisodeEntity>,
+ settings: ShowSettingsViewModel,
+ actionsFor: (EpisodeEntity) -> EpisodeActions,
modifier: Modifier,
) {
- val busy by vm.refreshing.collectAsState()
- // Which set the show lists is the show's own business, so it is read here rather than
- // passed in: the list and the drip switch above it can never disagree.
- val dripEnabled = subscription?.dripEnabled == true
- val episodes by vm.episodesOf(subscription?.id.orEmpty(), dripEnabled)
- .collectAsState(initial = emptyList())
+ val busy by settings.busy.collectAsState()
LazyColumn(modifier.fillMaxSize()) {
item { SubscriptionHeader(subscription) }
subscription?.let { show ->
- item {
- DripSettings(
- subscription = show,
- busy = busy,
- onEnable = { intervalMs, count, oldestFirst ->
- vm.enableDrip(show.id, intervalMs, count, oldestFirst)
- },
- onDisable = { vm.disableDrip(show.id) },
- onFetchArchive = { vm.fetchArchive(show.id) },
- )
- HorizontalDivider(Modifier.padding(top = 12.dp))
- }
+ item { ShowSettings(show, settings, busy) }
}
items(episodes, key = { it.id }) { episode ->
EpisodeRow(
episode = episode,
artworkUrl = Artwork.preferredUrl(episode.imageUrl, subscription?.imageUrl),
- actions = EpisodeActions(
- play = { player.play(episode) },
- enqueue = { vm.enqueue(episode.id) },
- playNext = { vm.playNext(episode.id) },
- download = { vm.download(episode) },
- openDetails = { nav.openEpisode(episode.id) },
- togglePlayed = {
- if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
- },
- ),
+ actions = actionsFor(episode),
)
HorizontalDivider()
}
}
}
+/** 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,
+) {
+ DripSettings(
+ subscription = show,
+ busy = busy,
+ onEnable = { intervalMs, count, oldestFirst ->
+ settings.enableDrip(show.id, intervalMs, count, oldestFirst)
+ },
+ onDisable = { settings.disableDrip(show.id) },
+ onFetchArchive = { settings.fetchArchive(show.id) },
+ )
+ HorizontalDivider(Modifier.padding(vertical = 12.dp))
+ SkipSettings(
+ subscription = show,
+ onChange = { introMs, outroMs -> settings.setSkips(show.id, introMs, outroMs) },
+ onSkipAdChapters = { settings.setSkipAdChapters(show.id, it) },
+ )
+ HorizontalDivider(Modifier.padding(top = 12.dp))
+}
+
@Composable
private fun SubscriptionHeader(subscription: SubscriptionEntity?) {
if (subscription == null) return
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 d5bc993..c1f7bd2 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -44,6 +44,14 @@ class FakeSubscriptionDao : SubscriptionDao {
replace(id) { it.copy(playbackSpeed = speed) }
}
+ override suspend fun setSkipAdChapters(id: String, enabled: Boolean) {
+ replace(id) { it.copy(skipAdChapters = enabled) }
+ }
+
+ override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) {
+ replace(id) { it.copy(skipIntroMs = introMs, skipOutroMs = outroMs) }
+ }
+
override suspend fun dripEnabled() = rows.filter { it.dripEnabled }
override suspend fun updateDrip(
@@ -173,6 +181,10 @@ class FakeEpisodeDao : EpisodeDao {
replace(id) { it.copy(downloadState = state, localPath = localPath, downloadedAtMs = downloadedAtMs) }
}
+ override suspend fun setChapters(id: String, json: String?) {
+ replace(id) { it.copy(chaptersJson = json) }
+ }
+
override suspend fun recordRule(id: String, ruleId: String, reason: String, speed: Float?) {
replace(id) { it.copy(appliedRuleId = ruleId, appliedRuleReason = reason, speedOverride = speed) }
}
diff --git a/app/src/test/java/org/terst/listen/feed/ChapterFetcherTest.kt b/app/src/test/java/org/terst/listen/feed/ChapterFetcherTest.kt
new file mode 100644
index 0000000..439485c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/ChapterFetcherTest.kt
@@ -0,0 +1,104 @@
+package org.terst.listen.feed
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+
+/** Fetching an episode's chapter file once and caching it on the episode. */
+class ChapterFetcherTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var episodes: FakeEpisodeDao
+ private lateinit var fetcher: ChapterFetcher
+
+ private val document = """{"chapters":[
+ {"startTime":0,"title":"Part one"},
+ {"startTime":60,"title":"Sponsor","toc":false}
+ ]}"""
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ episodes = FakeEpisodeDao()
+ fetcher = ChapterFetcher(OkHttpClient(), episodes)
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ private fun episode(chaptersUrl: String? = null, chaptersJson: String? = null): EpisodeEntity {
+ val entity = EpisodeEntity(
+ id = "e1",
+ subscriptionId = "s1",
+ guid = "g1",
+ title = "One",
+ mediaUrl = "https://cdn/1.mp3",
+ mediaType = "audio/mpeg",
+ chaptersUrl = chaptersUrl,
+ chaptersJson = chaptersJson,
+ discoveredAtMs = 0,
+ )
+ episodes.rows += entity
+ return entity
+ }
+
+ @Test
+ fun `an episode with no chapter url has no chapters`() = runTest {
+ assertEquals(emptyList<Chapter>(), fetcher.chapters(episode()))
+ }
+
+ @Test
+ fun `a chapter file is fetched and cached on the episode`() = runTest {
+ server.enqueue(MockResponse().setBody(document))
+
+ val chapters = fetcher.chapters(episode(chaptersUrl = server.url("/ch.json").toString()))
+
+ assertEquals(listOf("Part one", "Sponsor"), chapters.map { it.title })
+ assertNotNull("the file must be cached, not re-fetched every play", episodes.rows.single().chaptersJson)
+ }
+
+ /** Already cached means no network at all; the server would fail the request if asked. */
+ @Test
+ fun `a cached file is parsed without fetching again`() = runTest {
+ val chapters = fetcher.chapters(episode(chaptersUrl = "https://unused", chaptersJson = document))
+
+ assertEquals(2, chapters.size)
+ assertEquals(0, server.requestCount)
+ }
+
+ @Test
+ fun `a failed fetch yields no chapters and caches nothing`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(404))
+
+ val chapters = fetcher.chapters(episode(chaptersUrl = server.url("/ch.json").toString()))
+
+ assertEquals(emptyList<Chapter>(), chapters)
+ assertNull("a 404 must not be cached as an answer", episodes.rows.single().chaptersJson)
+ }
+
+ /**
+ * A file that fetches but parses to nothing is still cached.
+ *
+ * Otherwise every play of an episode whose publisher serves junk re-downloads it, forever,
+ * for a feature nobody asked for.
+ */
+ @Test
+ fun `an unparseable file is cached so it is not fetched again`() = runTest {
+ server.enqueue(MockResponse().setBody("this is not json"))
+
+ val chapters = fetcher.chapters(episode(chaptersUrl = server.url("/ch.json").toString()))
+
+ assertEquals(emptyList<Chapter>(), chapters)
+ assertNotNull(episodes.rows.single().chaptersJson)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/feed/ChapterParserTest.kt b/app/src/test/java/org/terst/listen/feed/ChapterParserTest.kt
new file mode 100644
index 0000000..42aa52a
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/ChapterParserTest.kt
@@ -0,0 +1,98 @@
+package org.terst.listen.feed
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Podcasting 2.0 chapter files.
+ *
+ * Tier 1 of the skip feature, and the only exact one: where a show marks its own ad breaks,
+ * detection is just parsing. This runs on a file fetched from a URL in a stranger's feed, so
+ * every malformed shape has to degrade to "no chapters" rather than take playback down.
+ */
+class ChapterParserTest {
+
+ @Test
+ fun `start times in seconds become milliseconds`() {
+ val chapters = ChapterParser.parse(
+ """{"version":"1.2.0","chapters":[
+ {"startTime":0,"title":"Cold open"},
+ {"startTime":47.5,"title":"Theme"}
+ ]}"""
+ )
+ assertEquals(listOf(0L, 47_500L), chapters.map { it.startMs })
+ assertEquals(listOf("Cold open", "Theme"), chapters.map { it.title })
+ }
+
+ @Test
+ fun `an end time is kept when the publisher gives one`() {
+ val chapters = ChapterParser.parse(
+ """{"chapters":[{"startTime":10,"endTime":75.25,"title":"Sponsor"}]}"""
+ )
+ assertEquals(10_000L, chapters.single().startMs)
+ assertEquals(75_250L, chapters.single().endMs)
+ }
+
+ /** `toc` is how a publisher says "this is not really part of the episode". */
+ @Test
+ fun `toc defaults to true and false is captured`() {
+ val chapters = ChapterParser.parse(
+ """{"chapters":[
+ {"startTime":0,"title":"Part one"},
+ {"startTime":60,"title":"Sponsor","toc":false}
+ ]}"""
+ )
+ assertEquals(listOf(true, false), chapters.map { it.inToc })
+ }
+
+ /** Publishers do emit them out of order, and everything downstream assumes they are not. */
+ @Test
+ fun `chapters come back in start order`() {
+ val chapters = ChapterParser.parse(
+ """{"chapters":[
+ {"startTime":120,"title":"Third"},
+ {"startTime":0,"title":"First"},
+ {"startTime":60,"title":"Second"}
+ ]}"""
+ )
+ assertEquals(listOf("First", "Second", "Third"), chapters.map { it.title })
+ }
+
+ @Test
+ fun `an empty chapter list is not an error`() {
+ assertEquals(emptyList<Chapter>(), ChapterParser.parse("""{"chapters":[]}"""))
+ }
+
+ @Test
+ fun `malformed json yields no chapters rather than throwing`() {
+ assertTrue(ChapterParser.parse("not json at all").isEmpty())
+ assertTrue(ChapterParser.parse("").isEmpty())
+ assertTrue(ChapterParser.parse("""{"chapters":"nonsense"}""").isEmpty())
+ }
+
+ /** A file with no chapters key at all is a valid JSON document and still means nothing. */
+ @Test
+ fun `json without a chapters key yields none`() {
+ assertTrue(ChapterParser.parse("""{"version":"1.2.0"}""").isEmpty())
+ }
+
+ /** Unknown keys are the norm: the spec has img, url, location and more. */
+ @Test
+ fun `unfamiliar fields are ignored rather than rejected`() {
+ val chapters = ChapterParser.parse(
+ """{"chapters":[{"startTime":5,"title":"One","img":"https://x/y.jpg",
+ "url":"https://x","location":{"name":"somewhere"}}]}"""
+ )
+ assertEquals(1, chapters.size)
+ assertEquals("One", chapters.single().title)
+ }
+
+ @Test
+ fun `a chapter with no start time is dropped`() {
+ val chapters = ChapterParser.parse(
+ """{"chapters":[{"title":"No start"},{"startTime":30,"title":"Fine"}]}"""
+ )
+ assertEquals(listOf("Fine"), chapters.map { it.title })
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/AdChaptersTest.kt b/app/src/test/java/org/terst/listen/playback/AdChaptersTest.kt
new file mode 100644
index 0000000..b74e09c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/AdChaptersTest.kt
@@ -0,0 +1,97 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.feed.Chapter
+
+/**
+ * Deciding whether the playhead is sitting in something the publisher marked as not-content.
+ *
+ * Conservative on purpose. A skip feature that silently eats an episode is worse than none, so
+ * the only signal trusted here is `toc: false` -- the publisher's own statement -- and a break
+ * whose end cannot be worked out is left alone rather than guessed at.
+ */
+class AdChaptersTest {
+
+ private val minute = 60_000L
+
+ private val chapters = listOf(
+ Chapter(startMs = 0, title = "Cold open"),
+ Chapter(startMs = 2 * minute, title = "Sponsor", inToc = false),
+ Chapter(startMs = 3 * minute, title = "The interview"),
+ )
+
+ @Test
+ fun `inside a marked break, it reports where to jump to`() {
+ assertEquals(3 * minute, AdChapters.skipTarget(chapters, 2 * minute + 5_000, 30 * minute))
+ }
+
+ @Test
+ fun `inside real content it reports nothing`() {
+ assertNull(AdChapters.skipTarget(chapters, minute, 30 * minute))
+ assertNull(AdChapters.skipTarget(chapters, 10 * minute, 30 * minute))
+ }
+
+ /** A boundary belongs to the chapter starting there, or a skip would fire again on arrival. */
+ @Test
+ fun `the first instant of the next chapter is not part of the break`() {
+ assertNull(AdChapters.skipTarget(chapters, 3 * minute, 30 * minute))
+ }
+
+ @Test
+ fun `an explicit end time is preferred over the next chapter`() {
+ val withEnd = listOf(
+ Chapter(startMs = 0, endMs = 90_000, title = "Sponsor", inToc = false),
+ Chapter(startMs = 5 * minute, title = "Content"),
+ )
+ assertEquals(90_000L, AdChapters.skipTarget(withEnd, 10_000, 30 * minute))
+ }
+
+ @Test
+ fun `a break that runs to the end of the episode jumps to the duration`() {
+ val trailing = listOf(
+ Chapter(startMs = 0, title = "Content"),
+ Chapter(startMs = 28 * minute, title = "Outro ad", inToc = false),
+ )
+ assertEquals(30 * minute, AdChapters.skipTarget(trailing, 29 * minute, 30 * minute))
+ }
+
+ /** With no end time, no following chapter and no duration, there is nowhere to jump to. */
+ @Test
+ fun `a break with no determinable end is left alone`() {
+ val trailing = listOf(
+ Chapter(startMs = 0, title = "Content"),
+ Chapter(startMs = 28 * minute, title = "Outro ad", inToc = false),
+ )
+ assertNull(AdChapters.skipTarget(trailing, 29 * minute, durationMs = null))
+ }
+
+ /**
+ * A chapter the publisher left in the table of contents is content, whatever it is called.
+ *
+ * Guessing from titles is how a skip feature starts eating the episode: a show discussing
+ * its own sponsorship deal has a chapter called "Sponsors" that is the actual programme.
+ */
+ @Test
+ fun `a chapter in the table of contents is never skipped, whatever its title`() {
+ val titled = listOf(
+ Chapter(startMs = 0, title = "Content"),
+ Chapter(startMs = 2 * minute, title = "Sponsors and advertising"),
+ Chapter(startMs = 4 * minute, title = "More content"),
+ )
+ assertNull(AdChapters.skipTarget(titled, 3 * minute, 30 * minute))
+ }
+
+ @Test
+ fun `no chapters means nothing to skip`() {
+ assertNull(AdChapters.skipTarget(emptyList(), minute, 30 * minute))
+ }
+
+ /** A position before the first chapter starts is not inside anything. */
+ @Test
+ fun `a position ahead of the first chapter is left alone`() {
+ val late = listOf(Chapter(startMs = 5 * minute, title = "Ad", inToc = false))
+ assertNull(AdChapters.skipTarget(late, minute, 30 * minute))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt
index 94749a0..d209fb6 100644
--- a/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackProgressTest.kt
@@ -112,4 +112,92 @@ class PlaybackProgressTest {
fun `the first write always happens`() {
assertTrue(progress.shouldPersist(lastWrittenMs = null, currentMs = 1_000))
}
+
+ // --- manual skip offsets -----------------------------------------------------------------
+ //
+ // Tier 2 of the skip feature. The fields have been on SubscriptionEntity since phase 1 and
+ // nothing read them; these are the rules that make them mean something.
+
+ @Test
+ fun `a fresh episode starts past the intro`() {
+ assertEquals(
+ 47_000L,
+ progress.resumePosition(storedMs = 0, durationMs = 60 * minute, skipIntroMs = 47_000),
+ )
+ }
+
+ /** Coming back to something half-heard must land where you left it, not at the offset. */
+ @Test
+ fun `a resumed episode ignores the intro offset`() {
+ assertEquals(
+ 30 * minute,
+ progress.resumePosition(storedMs = 30 * minute, durationMs = 60 * minute, skipIntroMs = 47_000),
+ )
+ }
+
+ /** A stored position past the end restarts, and restarting means past the intro too. */
+ @Test
+ fun `a restart also starts past the intro`() {
+ assertEquals(
+ 47_000L,
+ progress.resumePosition(storedMs = 90 * minute, durationMs = 60 * minute, skipIntroMs = 47_000),
+ )
+ }
+
+ /**
+ * A mistyped offset must not swallow the episode.
+ *
+ * Someone entering minutes where seconds were meant would otherwise start a 40-minute show
+ * at the end, which looks exactly like a broken download.
+ */
+ @Test
+ fun `an intro offset longer than the episode is ignored`() {
+ assertEquals(
+ 0L,
+ progress.resumePosition(storedMs = 0, durationMs = 40 * minute, skipIntroMs = 90 * minute),
+ )
+ }
+
+ @Test
+ fun `an unknown duration still honours the intro offset`() {
+ assertEquals(
+ 47_000L,
+ progress.resumePosition(storedMs = 0, durationMs = null, skipIntroMs = 47_000),
+ )
+ }
+
+ @Test
+ fun `the outro offset finishes an episode early`() {
+ assertFalse(progress.isComplete(positionMs = 50 * minute, durationMs = 60 * minute))
+ assertTrue(
+ progress.isComplete(
+ positionMs = 50 * minute,
+ durationMs = 60 * minute,
+ skipOutroMs = 10 * minute,
+ )
+ )
+ }
+
+ @Test
+ fun `an episode short of the outro offset is not complete`() {
+ assertFalse(
+ progress.isComplete(
+ positionMs = 40 * minute,
+ durationMs = 60 * minute,
+ skipOutroMs = 10 * minute,
+ )
+ )
+ }
+
+ /** Same reasoning as the intro: a nonsense offset must not mark everything played at once. */
+ @Test
+ fun `an outro offset as long as the episode is ignored`() {
+ assertFalse(
+ progress.isComplete(
+ positionMs = 1_000,
+ durationMs = 60 * minute,
+ skipOutroMs = 60 * minute,
+ )
+ )
+ }
}