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/LibraryOrderTest.kt62
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt83
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt60
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt42
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt41
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt37
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt3
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt7
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt3
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt50
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt112
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt32
-rw-r--r--app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt31
-rw-r--r--app/src/main/java/org/terst/listen/ui/DripSettings.kt59
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt44
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt125
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt34
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt28
-rw-r--r--app/src/main/java/org/terst/listen/ui/RulesViewModel.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingRows.kt59
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt109
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt50
-rw-r--r--app/src/main/java/org/terst/listen/ui/SkipSettings.kt35
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt101
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt2
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt12
-rw-r--r--app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt15
-rw-r--r--app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt60
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt2
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt209
-rw-r--r--app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt48
31 files changed, 1358 insertions, 199 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
index 4117b79..8f2f7b6 100644
--- a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
@@ -44,11 +44,17 @@ class LibraryOrderTest {
@After
fun tearDown() = db.close()
- private suspend fun show(id: String) = subscriptions.insert(
- SubscriptionEntity(id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0)
+ private suspend fun show(id: String, bedtime: Boolean = false) = subscriptions.insert(
+ SubscriptionEntity(
+ id = id,
+ feedUrl = "https://example.com/$id",
+ title = id,
+ addedAtMs = 0,
+ bedtime = bedtime,
+ )
)
- private suspend fun episode(id: String, showId: String, publishedAtMs: Long) =
+ private suspend fun episode(id: String, showId: String, publishedAtMs: Long?) =
episodes.insertAll(
listOf(
EpisodeEntity(
@@ -64,7 +70,14 @@ class LibraryOrderTest {
)
)
- private suspend fun order() = subscriptions.observeAll().first().map { it.id }
+ private suspend fun listing(bedtime: Boolean = false) =
+ subscriptions.observeAll(bedtime).first()
+
+ private suspend fun order(bedtime: Boolean = false) =
+ listing(bedtime).map { it.subscription.id }
+
+ private suspend fun waiting(id: String) =
+ listing().first { it.subscription.id == id }.hasWaiting
@Test
fun ranksByTheNewestEpisodeStillWaiting() = runBlocking {
@@ -151,4 +164,45 @@ class LibraryOrderTest {
assertEquals(listOf("active", "empty"), order())
}
+
+ /** Bedtime shows are a separate list, not a re-ordering of the same one. */
+ @Test
+ fun bedtimeShowsAreOutOfTheOrdinaryLibrary() = runBlocking {
+ show("daytime")
+ show("sleepy", bedtime = true)
+ episode("e1", "daytime", 1 * day)
+ episode("e2", "sleepy", 5 * day)
+
+ assertEquals("the newer show is a bedtime one and must not appear", listOf("daytime"), order())
+ assertEquals(listOf("sleepy"), order(bedtime = true))
+ }
+
+ /** Which is what greys a show out: nothing unplayed and unqueued left. */
+ @Test
+ fun hasWaitingGoesFalseOncePlayedAndQueuedAccountForEverything() = runBlocking {
+ show("a")
+ episode("played", "a", 1 * day)
+ episode("queued", "a", 2 * day)
+ assertEquals(true, waiting("a"))
+
+ episodes.markPlayed("played", 1)
+ queue.replaceAll(listOf("queued"))
+
+ assertEquals(false, waiting("a"))
+ }
+
+ /**
+ * The trap in computing this from the sort key instead of a count.
+ *
+ * Ranking uses MAX(COALESCE(publishedAtMs, 0)), so a show whose only waiting episode has no
+ * publish date ranks at 0 -- correct for ordering, but reading "has something waiting" off
+ * that same 0 would grey out a show that does.
+ */
+ @Test
+ fun aWaitingEpisodeWithNoPublishDateStillCounts() = runBlocking {
+ show("undated")
+ episode("e1", "undated", null)
+
+ assertEquals(true, waiting("undated"))
+ }
}
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 09b669f..091d8e6 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -333,4 +333,87 @@ class MigrationTest {
assertEquals(90000L, cursor.getLong(1))
}
}
+
+ /**
+ * Bedtime shows. Every existing subscription must come through as *not* bedtime: the flag
+ * hides a show from the library, so a default that went the other way would empty it.
+ */
+ @Test
+ fun migrates8To9DefaultingEveryExistingShowToNotBedtime() {
+ helper.createDatabase(name, 8).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1)"""
+ )
+ 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,
+ 515151, 'DOWNLOADED')"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 9, true, AppDatabase.MIGRATION_8_9)
+
+ db.query("SELECT bedtime, title FROM subscriptions WHERE id = 's1'").use { cursor ->
+ assertEquals(1, cursor.count)
+ cursor.moveToFirst()
+ assertEquals("an existing show must stay in the library", 0, cursor.getInt(0))
+ assertEquals("A Show", cursor.getString(1))
+ }
+
+ db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals("the crown jewel must survive", 515151L, cursor.getLong(0))
+ }
+ }
+
+ /**
+ * Drops `pendingClipPcm`, which could hold a whole 240-second decoded clip -- 3.84MB, well
+ * past the 2MB a CursorWindow will carry. Rows written with one could be inserted and then
+ * never read back: reproduced on an emulator as a hard crash the moment a show's second
+ * episode was fingerprinted.
+ *
+ * The surviving columns must come through, because the confirmed reference clip is the part
+ * worth keeping -- losing it would restart detection for every show.
+ */
+ @Test
+ fun migrates9To10DroppingTheOversizedPendingClipAndKeepingTheRest() {
+ helper.createDatabase(name, 9).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1, 0)"""
+ )
+ db.execSQL(
+ """INSERT INTO show_fingerprints
+ (subscriptionId, kind, referenceClipPcm, pendingClipPcm, pendingEpisodeId,
+ sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs)
+ VALUES ('s1', 'INTRO', X'0A0B', X'0C0D', 'e9', 8000, 2, 'e8', 4242)"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 10, true, AppDatabase.MIGRATION_9_10)
+
+ db.query(
+ """SELECT referenceClipPcm, pendingEpisodeId, confirmedCount, lastConfirmedEpisodeId,
+ sampleRate, updatedAtMs
+ FROM show_fingerprints WHERE subscriptionId = 's1' AND kind = 'INTRO'"""
+ ).use { cursor ->
+ assertEquals(1, cursor.count)
+ cursor.moveToFirst()
+ assertEquals("a confirmed reference clip is worth keeping", 2, cursor.getBlob(0).size)
+ assertEquals("e9", cursor.getString(1))
+ assertEquals(2, cursor.getInt(2))
+ assertEquals("e8", cursor.getString(3))
+ assertEquals(8000, cursor.getInt(4))
+ assertEquals(4242L, cursor.getLong(5))
+ }
+ }
}
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 e8769d3..d1b7ed2 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -30,7 +30,7 @@ class Converters {
ShowMidrollEntity::class,
EpisodeMidrollEntity::class,
],
- version = 8,
+ version = 10,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -230,6 +230,62 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Bedtime shows: the ones Peter only listens to falling asleep.
+ *
+ * Defaulted off, and it has to be. The flag takes a show out of the library entirely,
+ * so a default of 1 here would hand an upgrader an empty list and look like data loss.
+ */
+ val MIGRATION_8_9 = object : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `bedtime` INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+
+ /**
+ * Drops `show_fingerprints.pendingClipPcm`, which could not be read back.
+ *
+ * It held a whole decoded head or tail window -- 240 seconds at 8kHz, 3.84MB as 16-bit
+ * PCM -- and Android reads every Room row through a CursorWindow, which refuses a row
+ * over 2MB. The insert succeeded and the next read threw SQLiteBlobTooBigException, so
+ * the intro/outro half of Tier 3 crashed the moment a show's second episode was
+ * fingerprinted. Found on an emulator; it could not have shown up off-device.
+ *
+ * The pending episode's *id* stays, and its audio is re-decoded on demand -- exactly
+ * what [SubscriptionEntity.midrollPendingEpisodeId] already does, for the same reason.
+ * Recreated rather than altered because SQLite cannot drop a column in place on the
+ * versions this app supports; every other column is copied across, since a confirmed
+ * reference clip is worth keeping.
+ */
+ val MIGRATION_9_10 = object : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `show_fingerprints_new` (
+ `subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL,
+ `referenceClipPcm` BLOB, `pendingEpisodeId` TEXT,
+ `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL,
+ `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL,
+ PRIMARY KEY(`subscriptionId`, `kind`),
+ FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`)
+ ON DELETE CASCADE)"""
+ )
+ db.execSQL(
+ """INSERT INTO `show_fingerprints_new`
+ (subscriptionId, kind, referenceClipPcm, pendingEpisodeId, sampleRate,
+ confirmedCount, lastConfirmedEpisodeId, updatedAtMs)
+ SELECT subscriptionId, kind, referenceClipPcm, pendingEpisodeId,
+ sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs
+ FROM `show_fingerprints`"""
+ )
+ db.execSQL("DROP TABLE `show_fingerprints`")
+ db.execSQL("ALTER TABLE `show_fingerprints_new` RENAME TO `show_fingerprints`")
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` " +
+ "ON `show_fingerprints` (`subscriptionId`)"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -240,7 +296,7 @@ abstract class AppDatabase : RoomDatabase() {
// deliberate addition, and the list is the record of them.
.addMigrations(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6,
- MIGRATION_6_7, MIGRATION_7_8,
+ MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10,
)
.addCallback(SEED_DEFAULT_FILTERS)
// No fallbackToDestructiveMigration. A listener's playback positions and play
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 541d6ef..4fb6342 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -1,6 +1,7 @@
package org.terst.listen.data
import androidx.room.Dao
+import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@@ -8,6 +9,18 @@ import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
+/**
+ * A show as the library lists it: the row, plus whether anything of it is still waiting.
+ *
+ * [hasWaiting] is what greys a show out. It is computed in the same query as the ordering
+ * because both answer the same question about the same subquery, and asking twice would let
+ * the list and the dimming disagree.
+ */
+data class SubscriptionListing(
+ @Embedded val subscription: SubscriptionEntity,
+ val hasWaiting: Boolean,
+)
+
@Dao
interface SubscriptionDao {
/**
@@ -20,17 +33,36 @@ interface SubscriptionDao {
*
* Queued episodes are excluded for the same reason played ones are: they have been dealt
* with. Peter's words -- once they're on the queue they're noise in the list.
+ *
+ * [bedtime] picks which of the two lists this is. They are disjoint, not a filter over one
+ * list: a bedtime show is absent from the ordinary library entirely, which is the point.
+ *
+ * [SubscriptionListing.hasWaiting] comes from a COUNT rather than from `latest`, even
+ * though the sort key is right there. An episode with no publish date ranks at 0, which is
+ * fine for ordering and wrong for "has something waiting" -- reading the flag off the sort
+ * key would grey out a show that has an undated episode sitting unheard.
*/
@Query(
- """SELECT s.* FROM subscriptions s
- LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest
+ """SELECT s.*, COALESCE(e.waiting, 0) > 0 AS hasWaiting FROM subscriptions s
+ LEFT JOIN (SELECT subscriptionId, COUNT(*) AS waiting,
+ MAX(COALESCE(publishedAtMs, 0)) AS latest
FROM episodes
WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue)
GROUP BY subscriptionId) e
ON e.subscriptionId = s.id
+ WHERE s.bedtime = :bedtime
ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE"""
)
- fun observeAll(): Flow<List<SubscriptionEntity>>
+ fun observeAll(bedtime: Boolean): Flow<List<SubscriptionListing>>
+
+ /**
+ * Every show, bedtime ones included.
+ *
+ * Deliberately not the library query: a rule may well target a bedtime show, and a screen
+ * that has navigated into one must keep rendering it after bedtime mode is switched off.
+ */
+ @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
+ fun observeEvery(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
suspend fun getAll(): List<SubscriptionEntity>
@@ -67,6 +99,10 @@ interface SubscriptionDao {
@Query("UPDATE subscriptions SET skipAdChapters = :enabled WHERE id = :id")
suspend fun setSkipAdChapters(id: String, enabled: Boolean)
+ /** Moves a show between the ordinary library and the bedtime one. */
+ @Query("UPDATE subscriptions SET bedtime = :enabled WHERE id = :id")
+ suspend fun setBedtime(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)
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 4f5242d..83ee56a 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -102,15 +102,25 @@ data class SubscriptionEntity(
* The one episode set aside to compare the *next* newly-downloaded episode against, when
* looking for a midroll segment neither has been matched to yet.
*
- * Unlike [ShowFingerprintEntity.pendingClipPcm] this stores only an id, never audio: a
- * pending clip for intro/outro is a few seconds, cheap to persist between runs, but a
- * pending clip here is a *whole episode* -- tens of megabytes of PCM doubles for an hour of
- * audio, uselessly large to keep in a database row. [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater]
- * re-decodes this episode's file on demand instead, and if retention has already reclaimed
- * it, simply moves the pending slot on to whatever is newest -- the same graceful "nothing to
- * compare against yet" path as never having had a pending episode at all.
+ * An id, never audio -- the same rule [ShowFingerprintEntity.pendingEpisodeId] follows, and
+ * for the same reason: a pending clip here is a *whole episode*, tens of megabytes of PCM
+ * for an hour of audio, far past what a database row can carry back out.
+ * [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater] re-decodes this episode's
+ * file on demand instead, and if retention has already reclaimed it, simply moves the
+ * pending slot on to whatever is newest -- the same graceful "nothing to compare against
+ * yet" path as never having had a pending episode at all.
*/
val midrollPendingEpisodeId: String? = null,
+
+ /**
+ * A show only listened to falling asleep, kept out of the ordinary library.
+ *
+ * Not a category system: the one grouping that earns its keep here is the one with a
+ * behaviour attached, which is that entering bedtime mode also starts the sleep timer.
+ * A show marked this way is invisible until that mode is on, so the default must be false
+ * -- see [AppDatabase.MIGRATION_8_9].
+ */
+ @ColumnInfo(defaultValue = "0") val bedtime: Boolean = false,
)
/** One a day, the cadence a drip subscription is for. */
@@ -279,13 +289,16 @@ data class ShowFingerprintEntity(
*/
val referenceClipPcm: ByteArray?,
/**
- * The most recently processed episode's *whole* head/tail clip, kept only while there is no
- * [referenceClipPcm] yet -- the next episode needs something to compare against to find a
- * first candidate. Null again as soon as one is found; unlike the reference clip this is
- * genuinely temporary and, unlike it, sized like a whole decoded clip rather than a short
- * matched segment.
+ * The episode set aside to compare the *next* one against, while there is no
+ * [referenceClipPcm] yet -- finding a first candidate needs two episodes.
+ *
+ * An id, never the audio. The clip itself is a whole decoded head or tail window: 240
+ * seconds at 8kHz is 3.84MB as 16-bit PCM, and Android reads every Room row through a
+ * CursorWindow that refuses anything over 2MB. Storing it wrote a row that could never be
+ * read back. It is re-decoded from this episode's file on demand instead, exactly as
+ * [SubscriptionEntity.midrollPendingEpisodeId] already does; if retention has reclaimed
+ * the file, detection simply starts again from whatever is newest.
*/
- val pendingClipPcm: ByteArray?,
val pendingEpisodeId: String?,
val sampleRate: Int,
/**
@@ -308,7 +321,6 @@ data class ShowFingerprintEntity(
return subscriptionId == other.subscriptionId &&
kind == other.kind &&
referenceClipPcm.contentEqualsOrBothNull(other.referenceClipPcm) &&
- pendingClipPcm.contentEqualsOrBothNull(other.pendingClipPcm) &&
pendingEpisodeId == other.pendingEpisodeId &&
sampleRate == other.sampleRate &&
confirmedCount == other.confirmedCount &&
@@ -320,7 +332,6 @@ data class ShowFingerprintEntity(
var result = subscriptionId.hashCode()
result = 31 * result + kind.hashCode()
result = 31 * result + (referenceClipPcm?.contentHashCode() ?: 0)
- result = 31 * result + (pendingClipPcm?.contentHashCode() ?: 0)
result = 31 * result + (pendingEpisodeId?.hashCode() ?: 0)
result = 31 * result + sampleRate
result = 31 * result + confirmedCount
diff --git a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
index dc627fa..7018103 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
@@ -1,5 +1,6 @@
package org.terst.listen.fingerprint
+import org.terst.listen.data.EpisodeDao
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.FingerprintKind
import org.terst.listen.data.ShowFingerprintDao
@@ -40,6 +41,7 @@ interface AudioClipSource {
*/
class FingerprintUpdater(
private val fingerprints: ShowFingerprintDao,
+ private val episodes: EpisodeDao,
private val clips: AudioClipSource,
private val referenceLengthSamples: Int = DEFAULT_REFERENCE_SECONDS * DEFAULT_SAMPLE_RATE,
private val now: () -> Long = System::currentTimeMillis,
@@ -53,7 +55,7 @@ class FingerprintUpdater(
private suspend fun processEnd(episode: EpisodeEntity, kind: FingerprintKind, clip: DecodedClip) {
val stored = fingerprints.get(episode.subscriptionId, kind)
- val state = stateOf(stored)
+ val state = stateOf(stored, kind)
val update = IntroOutroDetector.check(state, episode.id, clip.samples, referenceLengthSamples)
fingerprints.upsert(entityOf(update.state, episode.subscriptionId, kind, clip.sampleRate))
@@ -84,17 +86,40 @@ class FingerprintUpdater(
}
}
- private fun stateOf(stored: ShowFingerprintEntity?): FingerprintState {
+ private suspend fun stateOf(stored: ShowFingerprintEntity?, kind: FingerprintKind): FingerprintState {
if (stored == null) return FingerprintState()
return FingerprintState(
referenceClip = stored.referenceClipPcm?.let(PcmCodec::decode),
- pendingClip = stored.pendingClipPcm?.let(PcmCodec::decode),
+ pendingClip = pendingClipOf(stored, kind),
pendingEpisodeId = stored.pendingEpisodeId,
confirmedCount = stored.confirmedCount,
lastConfirmedEpisodeId = stored.lastConfirmedEpisodeId,
)
}
+ /**
+ * Re-decodes the pending episode rather than reading a clip back out of the database.
+ *
+ * The clip is a whole head or tail window and does not fit in a row -- see
+ * [org.terst.listen.data.ShowFingerprintEntity.pendingEpisodeId]. Decoding again costs one
+ * pass over a file already on disk, and only until a first candidate exists.
+ *
+ * Null whenever that episode or its file has gone: the detector then treats the episode in
+ * hand as the new pending one, which is the same place it starts from anyway.
+ */
+ private suspend fun pendingClipOf(stored: ShowFingerprintEntity, kind: FingerprintKind): DoubleArray? {
+ if (stored.referenceClipPcm != null) return null
+ val pendingId = stored.pendingEpisodeId ?: return null
+ val pending = episodes.getById(pendingId) ?: return null
+ if (pending.localPath == null) return null
+
+ val clip = when (kind) {
+ FingerprintKind.INTRO -> clips.decodeHead(pending)
+ FingerprintKind.OUTRO -> clips.decodeTail(pending)
+ }
+ return clip?.samples
+ }
+
private fun entityOf(
state: FingerprintState,
subscriptionId: String,
@@ -104,7 +129,6 @@ class FingerprintUpdater(
subscriptionId = subscriptionId,
kind = kind,
referenceClipPcm = state.referenceClip?.let(PcmCodec::encode),
- pendingClipPcm = state.pendingClip?.let(PcmCodec::encode),
pendingEpisodeId = state.pendingEpisodeId,
sampleRate = sampleRate,
confirmedCount = state.confirmedCount,
@@ -112,9 +136,10 @@ class FingerprintUpdater(
updatedAtMs = now(),
)
- private companion object {
+ companion object {
+ /** Public so [org.terst.listen.fingerprint.StoredClip]'s guard can measure them. */
const val DEFAULT_REFERENCE_SECONDS = 8
const val DEFAULT_SAMPLE_RATE = 8_000
- const val MS_PER_SECOND = 1_000L
+ private const val MS_PER_SECOND = 1_000L
}
}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt
index 3a446d7..9050466 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt
@@ -55,7 +55,8 @@ sealed interface FingerprintUpdate {
*/
object IntroOutroDetector {
- private const val CONFIRM_THRESHOLD = 3
+ /** Public so the UI can say "2 of 3" rather than hardcoding the same number again. */
+ const val CONFIRM_THRESHOLD = 3
private const val MIN_SCORE = 0.6
private const val CANDIDATE_STEP_DIVISOR = 4
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
index 133d391..20f4417 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
@@ -188,7 +188,12 @@ class MediaCodecClipSource : AudioClipSource {
}
}
- private companion object {
+ companion object {
+ /**
+ * The head/tail window decoded for matching. Public because [StoredClip]'s size guard
+ * measures it: this window is far too big to persist, and a test says so rather than a
+ * comment nobody reads.
+ */
const val CLIP_SECONDS = 240
// durationMs is the feed's own claim, occasionally a little short of the real file.
const val FULL_DECODE_MARGIN_SECONDS = 30
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt
index b33f577..8cbb441 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt
@@ -15,7 +15,8 @@ package org.terst.listen.fingerprint
*/
object MidrollDetector {
- private const val CONFIRM_THRESHOLD = 3
+ /** Public for the same reason as [IntroOutroDetector.CONFIRM_THRESHOLD]. */
+ const val CONFIRM_THRESHOLD = 3
private const val MIN_SCORE = 0.6
data class Candidate(
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt
index 47f9184..5849d1d 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt
@@ -93,28 +93,30 @@ class MidrollUpdater(
val pendingEpisode = episodes.getById(pendingEpisodeId) ?: return
val pendingClip = clips.decodeFull(pendingEpisode) ?: return
+ // The same real segment surfaces from a fresh discovery pass every time the pending
+ // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked.
+ // Recognised by *where* it landed in the new episode, not by re-correlating clip
+ // content: two coarse passes over the same segment do not reliably extract the same
+ // sub-range of it, so a content comparison between them can score low even though both
+ // come from the one real ad break -- position overlap is what is actually stable
+ // between runs.
val tolerance = discoverySamples.toLong() * coarseFactor
- for (segment in discover(pendingClip.samples, newClip.samples)) {
- // The same real segment surfaces from a fresh discovery pass every time the pending
- // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked.
- // Recognised here by *where* it landed in the new episode, not by re-correlating
- // clip content: two coarse passes over the same segment do not reliably extract the
- // same sub-range of it, so a content comparison between them can score low even
- // though both come from the one real ad break -- position overlap is what is
- // actually stable between runs.
- val overlapsTracked = alreadyMatchedInNewEpisode.any { existing ->
+ val undiscovered = discover(pendingClip.samples, newClip.samples).filterNot { segment ->
+ alreadyMatchedInNewEpisode.any { existing ->
rangesOverlap(
segment.positionInB - tolerance, segment.positionInB + segment.lengthInB + tolerance,
existing.positionInClip.toLong(), existing.positionInClip.toLong() + existing.lengthSamples,
)
}
- if (overlapsTracked) continue
+ }
+ for (segment in undiscovered) {
+ val pcm = storablePcm(segment.referenceClip, newClip.sampleRate) ?: continue
midrolls.upsertCandidate(
ShowMidrollEntity(
subscriptionId = subscriptionId,
id = newId(),
- referenceClipPcm = PcmCodec.encode(segment.referenceClip),
+ referenceClipPcm = pcm,
sampleRate = newClip.sampleRate,
confirmedCount = SEEDED_CONFIRM_COUNT,
lastConfirmedEpisodeId = newEpisode.id,
@@ -124,6 +126,29 @@ class MidrollUpdater(
}
}
+ /**
+ * A discovered segment as bytes a database row can carry back out, or null if even the
+ * trimmed clip would not fit.
+ *
+ * A shared segment has no length limit of its own -- a long sponsor block could run for
+ * minutes, and at 8kHz 16-bit PCM two minutes is already past the 2MB a CursorWindow will
+ * return. See [StoredClip]; the fingerprint table shipped with exactly that bug, and it
+ * crashed the app on read rather than failing on write.
+ *
+ * Trimming shortens the range that gets skipped, so a very long break plays its tail rather
+ * than being cut entirely. That is the safe direction to be wrong in: over-skipping eats
+ * real content, and not trimming at all makes a row that cannot be read.
+ *
+ * The [StoredClip.fits] check afterwards is a backstop, unreachable while the cap and the
+ * limit agree. It returns null rather than throwing because an untracked ad break is a
+ * missing feature and an oversized row is a crash.
+ */
+ private fun storablePcm(clip: DoubleArray, sampleRate: Int): ByteArray? {
+ val maxSamples = StoredClip.MAX_CANDIDATE_SECONDS * sampleRate
+ val trimmed = if (clip.size <= maxSamples) clip else clip.copyOf(maxSamples)
+ return PcmCodec.encode(trimmed).takeIf { StoredClip.fits(it.size) }
+ }
+
private fun rangesOverlap(aStart: Long, aEnd: Long, bStart: Long, bEnd: Long) = aStart < bEnd && bStart < aEnd
private data class DiscoveredSegment(val referenceClip: DoubleArray, val positionInB: Long, val lengthInB: Long)
@@ -181,11 +206,12 @@ class MidrollUpdater(
if (row.id !in surviving) midrolls.deleteCandidate(subscriptionId, row.id)
}
for (candidate in result.candidates) {
+ val pcm = storablePcm(candidate.referenceClip, sampleRate) ?: continue
midrolls.upsertCandidate(
ShowMidrollEntity(
subscriptionId = subscriptionId,
id = candidate.id,
- referenceClipPcm = PcmCodec.encode(candidate.referenceClip),
+ referenceClipPcm = pcm,
sampleRate = sampleRate,
confirmedCount = candidate.confirmedCount,
lastConfirmedEpisodeId = candidate.lastConfirmedEpisodeId,
diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
new file mode 100644
index 0000000..3718f58
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
@@ -0,0 +1,112 @@
+package org.terst.listen.fingerprint
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.ShowFingerprintEntity
+import org.terst.listen.data.ShowMidrollEntity
+import org.terst.listen.data.SubscriptionEntity
+
+/** The three lines the episode screen shows about skipping. [breaks] is never empty. */
+data class EpisodeSkips(val intro: String, val outro: String, val breaks: List<String>)
+
+/**
+ * Says in words what the skip detection has worked out, so it stops being invisible.
+ *
+ * Tier 3 decides on its own, in a background worker, whether to cut audio out of an episode.
+ * Until this existed there was no way to tell a show it had confirmed an intro for from one it
+ * had never looked at -- both simply played. The distinction the readout has to carry is
+ * confirmed-and-acting versus still-gathering, because only the first one skips anything.
+ *
+ * Pure, and the wording is pinned by tests: this is the only explanation of the feature the app
+ * ever gives.
+ */
+object SkipSummary {
+
+ private const val MS_PER_SECOND = 1000L
+ private const val SECONDS_PER_MINUTE = 60
+ private const val MINUTES_PER_HOUR = 60
+
+ /** One end of a show's episodes -- its intro or its outro. */
+ fun candidateState(state: ShowFingerprintEntity?, threshold: Int): String {
+ // No reference clip means no candidate: either nothing has been processed, or a single
+ // episode's clip is set aside with nothing yet to compare it against. Both are the same
+ // thing to a reader -- detection has not found anything.
+ if (state?.referenceClipPcm == null) return "Nothing found yet"
+
+ return if (state.confirmedCount >= threshold) {
+ "Confirmed by ${state.confirmedCount} episodes"
+ } else {
+ "Found, ${state.confirmedCount} of $threshold episodes agree"
+ }
+ }
+
+ /** A show's midroll candidates, which unlike intro/outro can be several at once. */
+ fun midrollState(candidates: List<ShowMidrollEntity>, threshold: Int): String {
+ if (candidates.isEmpty()) return "None found yet"
+
+ val confirmed = candidates.count { it.confirmedCount >= threshold }
+ val gathering = candidates.size - confirmed
+
+ return listOfNotNull(
+ "$confirmed confirmed".takeIf { confirmed > 0 },
+ "$gathering still gathering".takeIf { gathering > 0 },
+ ).joinToString(", ")
+ }
+
+ /**
+ * The offset actually in force for one episode, and where it came from.
+ *
+ * Mirrors [org.terst.listen.playback.effectiveIntroMs]: a detected value wins over the
+ * manual one. A detected *zero* is a real finding -- this show turned out to have no intro
+ * -- and must not read as "nothing detected", or the readout would disagree with what
+ * playback is doing.
+ */
+ private fun offset(detectedMs: Long?, manualMs: Long): String = when {
+ detectedMs != null -> "${describe(detectedMs)}, detected"
+ manualMs > 0 -> "${describe(manualMs)}, set by hand"
+ else -> "None"
+ }
+
+ /** Detected ad breaks in one episode, earliest first, as clock times you can scrub to. */
+ private fun ranges(ranges: List<EpisodeMidrollEntity>): List<String> =
+ ranges.sortedBy { it.startMs }.map { "${clock(it.startMs)} - ${clock(it.endMs)}" }
+
+ /**
+ * Everything the episode screen says about skipping, decided here rather than in the
+ * composable so the "not checked yet" case is actually covered by a test.
+ */
+ fun episode(
+ episode: EpisodeEntity,
+ show: SubscriptionEntity?,
+ ranges: List<EpisodeMidrollEntity>,
+ ): EpisodeSkips {
+ val breaks = ranges(ranges)
+ return EpisodeSkips(
+ intro = offset(episode.detectedIntroMs, show?.skipIntroMs ?: 0),
+ outro = offset(episode.detectedOutroMs, show?.skipOutroMs ?: 0),
+ breaks = when {
+ breaks.isNotEmpty() -> breaks
+ // Distinct from "none found": scanning runs nightly while charging, so a
+ // freshly downloaded episode has genuinely not been looked at yet.
+ episode.midrollCheckedAtMs == null -> listOf("Not checked yet")
+ else -> listOf("None found")
+ },
+ )
+ }
+
+ private fun describe(ms: Long): String =
+ if (ms <= 0) "None" else "${ms / MS_PER_SECOND}s"
+
+ private fun clock(ms: Long): String {
+ val totalSeconds = ms / MS_PER_SECOND
+ val seconds = totalSeconds % SECONDS_PER_MINUTE
+ val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR
+ val hours = totalSeconds / SECONDS_PER_MINUTE / MINUTES_PER_HOUR
+
+ return if (hours > 0) {
+ "%d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%d:%02d".format(minutes, seconds)
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt
new file mode 100644
index 0000000..4d425df
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt
@@ -0,0 +1,32 @@
+package org.terst.listen.fingerprint
+
+/**
+ * How much decoded audio may be put in a database row.
+ *
+ * Android hands every Room read through a CursorWindow, which refuses a row larger than 2MB --
+ * on the way *out*. An oversized blob inserts cleanly and then throws
+ * `SQLiteBlobTooBigException` the next time anything reads it, which is how the intro/outro
+ * half of Tier 3 shipped unable to get past a show's second episode.
+ *
+ * Every persisted clip goes through [fits], so the limit is one shared fact rather than
+ * something each caller has to remember.
+ */
+object StoredClip {
+
+ /** Android's own cap on a row read through a CursorWindow. */
+ const val CURSOR_WINDOW_LIMIT_BYTES = 2 * 1024 * 1024
+
+ /** Half of it, so the columns alongside the blob can never tip an accepted clip over. */
+ const val SAFE_BYTES = CURSOR_WINDOW_LIMIT_BYTES / 2
+
+ /**
+ * The longest midroll segment worth keeping, at [MediaCodecClipSource.TARGET_SAMPLE_RATE].
+ *
+ * Sixty seconds is about 960KB, comfortably inside [SAFE_BYTES], and long enough to
+ * identify an ad break by correlation several times over -- matching is about whether the
+ * segment recurs, not about hearing all of it.
+ */
+ const val MAX_CANDIDATE_SECONDS = 60
+
+ fun fits(bytes: Int): Boolean = bytes <= SAFE_BYTES
+}
diff --git a/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt b/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt
new file mode 100644
index 0000000..de52742
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt
@@ -0,0 +1,31 @@
+package org.terst.listen.playback
+
+/** What switching bedtime mode should do to the sleep timer. */
+sealed interface BedtimeAction {
+ data class ArmSleepTimer(val minutes: Long) : BedtimeAction
+ data object CancelSleepTimer : BedtimeAction
+ data object Nothing : BedtimeAction
+}
+
+/**
+ * Bedtime mode's one side effect: it arms the sleep timer, and takes it away again.
+ *
+ * Pure, because both directions have a failure that is invisible until it bites. Arming over a
+ * timer the listener set by hand silently changes a duration they chose; cancelling on the way
+ * out when bedtime never armed anything silently kills that same timer. Neither shows up as a
+ * crash -- they show up as audio still playing at 3am, or stopping mid-sentence.
+ */
+object BedtimePolicy {
+
+ /**
+ * Long rather than short. [SleepTimer]'s own reasoning applies: stopping late costs
+ * nothing, stopping early wakes you up to restart it.
+ */
+ const val DEFAULT_MINUTES = 45L
+
+ fun entering(timerAlreadyArmed: Boolean): BedtimeAction =
+ if (timerAlreadyArmed) BedtimeAction.Nothing else BedtimeAction.ArmSleepTimer(DEFAULT_MINUTES)
+
+ fun leaving(bedtimeArmedIt: Boolean): BedtimeAction =
+ if (bedtimeArmedIt) BedtimeAction.CancelSleepTimer else BedtimeAction.Nothing
+}
diff --git a/app/src/main/java/org/terst/listen/ui/DripSettings.kt b/app/src/main/java/org/terst/listen/ui/DripSettings.kt
index 9b8188a..97ba063 100644
--- a/app/src/main/java/org/terst/listen/ui/DripSettings.kt
+++ b/app/src/main/java/org/terst/listen/ui/DripSettings.kt
@@ -3,19 +3,13 @@ package org.terst.listen.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
-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.unit.dp
import org.terst.listen.data.SubscriptionEntity
import java.util.concurrent.TimeUnit
@@ -41,43 +35,32 @@ fun DripSettings(
onFetchArchive: () -> Unit,
) {
Column(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Switch(
- // Named, because a bare switch has nothing for a screen reader to announce --
- // and nothing for the UI checker to tap by.
- modifier = Modifier.semantics { contentDescription = "Turn slow drip on or off" },
- checked = subscription.dripEnabled,
- onCheckedChange = { wanted ->
- if (wanted) {
- onEnable(
- subscription.dripIntervalMs,
- subscription.dripCount,
- subscription.dripOldestFirst,
- )
- } else {
- onDisable()
- }
- },
- )
- Column(Modifier.padding(start = 12.dp)) {
- Text("Slow drip", style = MaterialTheme.typography.titleSmall)
- Text(dripSummary(subscription), style = MaterialTheme.typography.bodySmall)
- }
- }
+ SwitchRow(
+ label = "Slow drip",
+ description = "Turn slow drip on or off",
+ checked = subscription.dripEnabled,
+ detail = dripSummary(subscription),
+ onCheckedChange = { wanted ->
+ if (wanted) {
+ onEnable(
+ subscription.dripIntervalMs,
+ subscription.dripCount,
+ subscription.dripOldestFirst,
+ )
+ } else {
+ onDisable()
+ }
+ },
+ )
if (subscription.dripEnabled) {
DripCadence(subscription, onEnable)
}
OutlinedButton(onClick = onFetchArchive, enabled = !busy) { Text("Fetch archive") }
- Text(
- "Pulls older episodes by following the feed's archive pages. Many publishers do not " +
- "offer them, in which case a drip can only work through what the feed lists today.",
- style = MaterialTheme.typography.bodySmall,
- )
}
}
@@ -127,11 +110,9 @@ private fun DripChip(label: String, selected: Boolean, onClick: () -> Unit) {
}
private fun dripSummary(subscription: SubscriptionEntity): String {
- if (!subscription.dripEnabled) {
- return "Off. The whole catalogue is listed."
- }
+ if (!subscription.dripEnabled) return "Whole catalogue listed"
val days = TimeUnit.MILLISECONDS.toDays(subscription.dripIntervalMs)
val every = if (days == 1L) "day" else "$days days"
val order = if (subscription.dripOldestFirst) "oldest" else "newest"
- return "${subscription.dripCount} $order first, every $every. Only released episodes are listed."
+ return "${subscription.dripCount} $order first, every $every"
}
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 0e161e3..09356f1 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -36,7 +36,13 @@ 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.data.EpisodeMidrollEntity
+import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.feed.WatchLink
+import org.terst.listen.fingerprint.EpisodeSkips
+import org.terst.listen.fingerprint.SkipSummary
+import org.terst.listen.playback.effectiveIntroMs
+import org.terst.listen.playback.effectiveOutroMs
/**
* One episode in full.
@@ -54,8 +60,13 @@ fun EpisodeScreen(
nav: SubscriptionNav,
) {
var episode by remember { mutableStateOf<EpisodeEntity?>(null) }
- LaunchedEffect(episodeId) { episode = vm.episode(episodeId) }
+ var ranges by remember { mutableStateOf<List<EpisodeMidrollEntity>>(emptyList()) }
+ LaunchedEffect(episodeId) {
+ episode = vm.episode(episodeId)
+ ranges = vm.midrollRanges(episodeId)
+ }
val rules by vm.rules.collectAsState()
+ val shows by vm.subscriptions.collectAsState()
val current = episode
Scaffold(
@@ -78,6 +89,11 @@ fun EpisodeScreen(
EpisodeDetail(
episode = current,
ruleName = rules.firstOrNull { rule -> rule.id == current.appliedRuleId }?.name,
+ skips = SkipSummary.episode(
+ current,
+ shows.firstOrNull { it.id == current.subscriptionId },
+ ranges,
+ ),
modifier = Modifier.padding(padding),
actions = EpisodeDetailActions(
play = { player.play(current); nav.openPlayer() },
@@ -97,6 +113,7 @@ fun EpisodeScreen(
private fun EpisodeDetail(
episode: EpisodeEntity,
ruleName: String?,
+ skips: EpisodeSkips,
modifier: Modifier,
actions: EpisodeDetailActions,
) {
@@ -140,6 +157,8 @@ private fun EpisodeDetail(
RuleAttribution(episode, ruleName)
+ SkipReadout(skips)
+
episode.description?.let {
Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium)
}
@@ -147,6 +166,29 @@ private fun EpisodeDetail(
}
/**
+ * What is actually being skipped in this episode, and where the numbers came from.
+ *
+ * The one place the detection is visible per episode. It deliberately reports what playback
+ * will really do -- [effectiveIntroMs] and [effectiveOutroMs] prefer a detected value over the
+ * manual one, so reading the manual field here would quietly contradict the player.
+ *
+ * "Not checked yet" is a distinct state from "nothing found". Midroll scanning runs nightly
+ * while charging, so a freshly downloaded episode legitimately has nothing to say yet, and
+ * reporting that as "no ad breaks" would look like the feature failing.
+ */
+@Composable
+private fun SkipReadout(skips: EpisodeSkips) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text("Skips", style = MaterialTheme.typography.titleSmall)
+ ReadoutRow("Intro", skips.intro)
+ ReadoutRow("Outro", skips.outro)
+ skips.breaks.forEachIndexed { index, line ->
+ ReadoutRow(if (index == 0) "Ad breaks" else "", line)
+ }
+ }
+}
+
+/**
* Show notes are HTML in practice, whatever the spec says.
*
* Rendered raw they appear as a wall of tags. This is not a parser — it strips tags, decodes the
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
index 16301aa..a3a1149 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Bedtime
+import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
@@ -47,15 +49,17 @@ import coil3.compose.AsyncImage
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionListing
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) {
- val subscriptions by vm.subscriptions.collectAsState()
+ val subscriptions by vm.library.collectAsState()
val queue by vm.queue.collectAsState()
val refreshing by vm.refreshing.collectAsState()
val banner by vm.banner.collectAsState()
+ val bedtime by vm.bedtime.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
val snackbar = remember { SnackbarHostState() }
var adding by remember { mutableStateOf(false) }
@@ -69,19 +73,22 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav)
Scaffold(
topBar = {
- TopAppBar(
- title = { Text("Listen") },
- actions = {
- IconButton(onClick = { adding = !adding }) {
- Icon(Icons.Default.Add, contentDescription = "Add a podcast")
- }
- IconButton(onClick = vm::refreshAll, enabled = !refreshing) {
- Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds")
- }
- IconButton(onClick = nav.openSettings) {
- Icon(Icons.Default.Settings, contentDescription = "Settings")
- }
- },
+ LibraryTopBar(
+ bedtime = bedtime,
+ refreshing = refreshing,
+ actions = LibraryTopBarActions(
+ toggleAdd = { adding = !adding },
+ refreshAll = vm::refreshAll,
+ // Both halves of the switch happen here rather than inside a view model:
+ // the show list is library state and the sleep timer is playback state,
+ // and they live in different view models by design.
+ toggleBedtime = {
+ val entering = !bedtime
+ vm.setBedtime(entering)
+ player.onBedtimeChanged(entering)
+ },
+ openSettings = nav.openSettings,
+ ),
)
},
snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
@@ -106,15 +113,53 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav)
AddSubscription(onSubmit = { vm.subscribe(it); adding = false })
}
- LibraryList(subscriptions, queue, nav.openSubscription, nav.openQueue)
+ LibraryList(subscriptions, queue, bedtime, nav.openSubscription, nav.openQueue)
}
}
}
+/** The top bar's four callbacks, bundled so the signature stays readable. */
+private data class LibraryTopBarActions(
+ val toggleAdd: () -> Unit,
+ val refreshAll: () -> Unit,
+ val toggleBedtime: () -> Unit,
+ val openSettings: () -> Unit,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LibraryTopBar(
+ bedtime: Boolean,
+ refreshing: Boolean,
+ actions: LibraryTopBarActions,
+) {
+ TopAppBar(
+ title = { Text(if (bedtime) "Bedtime" else "Listen") },
+ actions = {
+ IconButton(onClick = actions.toggleAdd) {
+ Icon(Icons.Default.Add, contentDescription = "Add a podcast")
+ }
+ IconButton(onClick = actions.refreshAll, enabled = !refreshing) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds")
+ }
+ IconButton(onClick = actions.toggleBedtime) {
+ Icon(
+ if (bedtime) Icons.Default.LightMode else Icons.Default.Bedtime,
+ contentDescription = if (bedtime) "Leave bedtime mode" else "Enter bedtime mode",
+ )
+ }
+ IconButton(onClick = actions.openSettings) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
+ },
+ )
+}
+
@Composable
private fun LibraryList(
- subscriptions: List<SubscriptionEntity>,
+ subscriptions: List<SubscriptionListing>,
queue: List<EpisodeEntity>,
+ bedtime: Boolean,
onOpenSubscription: (String) -> Unit,
onOpenQueue: () -> Unit,
) {
@@ -123,10 +168,10 @@ private fun LibraryList(
item { UpNextCard(queue, onOpenQueue) }
}
if (subscriptions.isEmpty()) {
- item { EmptyLibrary() }
+ item { if (bedtime) EmptyBedtime() else EmptyLibrary() }
}
- items(subscriptions, key = { it.id }) { subscription ->
- SubscriptionRow(subscription) { onOpenSubscription(subscription.id) }
+ items(subscriptions, key = { it.subscription.id }) { listing ->
+ SubscriptionRow(listing) { onOpenSubscription(listing.subscription.id) }
HorizontalDivider()
}
}
@@ -147,6 +192,20 @@ private fun UpNextCard(queue: List<EpisodeEntity>, onOpenQueue: () -> Unit) {
}
}
+/** Dimmed rather than hidden: a show you are caught up on is still one you subscribe to. */
+private const val CAUGHT_UP_ALPHA = 0.38f
+
+@Composable
+private fun EmptyBedtime() {
+ Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("No bedtime podcasts", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Mark a show as Bedtime in its menu and it moves here, out of the main list.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
@Composable
private fun EmptyLibrary() {
Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -177,21 +236,43 @@ private fun AddSubscription(onSubmit: (String) -> Unit) {
}
}
+/**
+ * One show. Dimmed when it has nothing waiting, so a scan down the list separates the shows
+ * with something to hear from the ones you are caught up on.
+ *
+ * The dimming stops short of [SubscriptionEntity.lastError], which keeps its error colour: a
+ * feed that is failing to refresh looks exactly like a feed with nothing new, and that is the
+ * one case where "nothing waiting" is a lie worth shouting about.
+ */
@Composable
-private fun SubscriptionRow(subscription: SubscriptionEntity, onClick: () -> Unit) {
+private fun SubscriptionRow(listing: SubscriptionListing, onClick: () -> Unit) {
+ val subscription = listing.subscription
+ val alpha = if (listing.hasWaiting) 1f else CAUGHT_UP_ALPHA
+
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) {
AsyncImage(
model = subscription.imageUrl,
contentDescription = null,
+ alpha = alpha,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
- Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
+ Text(
+ subscription.title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
+ maxLines = 2,
+ )
subscription.author?.let {
- Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1)
+ Text(
+ it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
+ maxLines = 1,
+ )
}
// A feed that has quietly stopped working is otherwise indistinguishable from one
// with nothing new, which is the whole reason the error is stored rather than logged.
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 2b520ec..831ec8f 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -3,10 +3,12 @@ package org.terst.listen.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
@@ -16,6 +18,7 @@ import org.terst.listen.data.RefreshOutcome
import org.terst.listen.data.RuleEntity
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.data.SubscriptionListing
import org.terst.listen.feed.FeedResolver
import org.terst.listen.feed.SubscriptionResolver
import org.terst.listen.feed.YouTubeUrls
@@ -27,10 +30,36 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
private val graph = Graph.get(app)
+ /**
+ * Whether the library is showing bedtime shows instead of the ordinary ones.
+ *
+ * Deliberately not persisted. It describes the next half hour, not a setting: waking up to
+ * an app still in bedtime mode, with the whole library apparently gone, is the failure this
+ * avoids. A restart is morning.
+ */
+ private val _bedtime = MutableStateFlow(false)
+ val bedtime: StateFlow<Boolean> = _bedtime.asStateFlow()
+
+ /** What the library screen lists, which of the two lists depending on [bedtime]. */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val library: StateFlow<List<SubscriptionListing>> =
+ _bedtime.flatMapLatest { graph.subscriptions.observeAll(it) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
+
+ /**
+ * Every show, for the screens that look one up by id.
+ *
+ * Separate from [library] on purpose: a show screen opened from bedtime mode must survive
+ * bedtime mode being switched off, and it would blank if it read the filtered list.
+ */
val subscriptions: StateFlow<List<SubscriptionEntity>> =
- graph.subscriptions.observeAll()
+ graph.subscriptions.observeEvery()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
+ fun setBedtime(enabled: Boolean) {
+ _bedtime.value = enabled
+ }
+
val queue: StateFlow<List<EpisodeEntity>> =
graph.queue.observe()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
@@ -52,6 +81,9 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
suspend fun episode(id: String): EpisodeEntity? = graph.episodes.getById(id)
+ /** The ad breaks detection has confirmed in one episode, for the episode screen's readout. */
+ suspend fun midrollRanges(episodeId: String) = graph.midrolls.getRanges(episodeId)
+
/**
* A show's episodes as it displays them.
*
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 cc28fe9..fed4960 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -18,6 +18,8 @@ import org.terst.listen.feed.WatchLink
import org.terst.listen.playback.NowPlaying
import org.terst.listen.playback.PlayerConnection
import org.terst.listen.playback.StartOutcome
+import org.terst.listen.playback.BedtimeAction
+import org.terst.listen.playback.BedtimePolicy
import org.terst.listen.playback.SleepTimer
import org.terst.listen.playback.toMediaItem
@@ -203,6 +205,31 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
fun cancelSleepTimer() {
sleepTimer.cancel()
_sleepRemainingMs.value = null
+ bedtimeArmedTimer = false
+ }
+
+ /**
+ * Tracked so leaving bedtime mode only cancels a timer bedtime mode started.
+ *
+ * Without it, flicking the moon icon off would silently kill a timer set by hand.
+ */
+ private var bedtimeArmedTimer = false
+
+ /** Bedtime mode's effect on playback: see [BedtimePolicy] for why each case is what it is. */
+ fun onBedtimeChanged(entering: Boolean) {
+ val action = if (entering) {
+ BedtimePolicy.entering(timerAlreadyArmed = sleepTimer.isArmed)
+ } else {
+ BedtimePolicy.leaving(bedtimeArmedIt = bedtimeArmedTimer)
+ }
+ when (action) {
+ is BedtimeAction.ArmSleepTimer -> {
+ armSleepTimer(action.minutes)
+ bedtimeArmedTimer = true
+ }
+ BedtimeAction.CancelSleepTimer -> cancelSleepTimer()
+ BedtimeAction.Nothing -> Unit
+ }
}
/**
@@ -219,6 +246,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
connection.pause()
sleepTimer.cancel()
_sleepRemainingMs.value = null
+ bedtimeArmedTimer = false
_banner.value = Banner("Sleep timer finished")
break
}
diff --git a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
index 5080011..98ae063 100644
--- a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
@@ -24,7 +24,7 @@ class RulesViewModel(app: Application) : AndroidViewModel(app) {
/** For the "only this show" condition, and for naming a scoped rule in the list. */
val subscriptions: StateFlow<List<SubscriptionEntity>> =
- graph.subscriptions.observeAll()
+ graph.subscriptions.observeEvery()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
/**
diff --git a/app/src/main/java/org/terst/listen/ui/SettingRows.kt b/app/src/main/java/org/terst/listen/ui/SettingRows.kt
new file mode 100644
index 0000000..a1a6db9
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/SettingRows.kt
@@ -0,0 +1,59 @@
+package org.terst.listen.ui
+
+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.material3.MaterialTheme
+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.unit.dp
+
+/**
+ * A labelled switch, with the accessibility name every switch in this app has to carry.
+ *
+ * A bare `Switch` announces nothing to TalkBack and cannot be found by `android-ui tap`, and
+ * [description] has to differ from [label] -- two identical strings beside each other make the
+ * control ambiguous to pick, for a screen reader and the UI checker alike.
+ */
+@Composable
+fun SwitchRow(
+ label: String,
+ description: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ detail: String? = null,
+) {
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Switch(
+ modifier = Modifier.semantics { contentDescription = description },
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ )
+ Column(Modifier.padding(start = 12.dp)) {
+ Text(label, style = MaterialTheme.typography.bodyLarge)
+ detail?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
+ }
+ }
+}
+
+/** A label and its current value on one line -- the shape every read-only readout here uses. */
+@Composable
+fun ReadoutRow(label: String, value: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt
new file mode 100644
index 0000000..b387a2c
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt
@@ -0,0 +1,109 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * Everything that is a setting for one show, in a sheet rather than stacked above its episodes.
+ *
+ * These used to sit inline at the top of the show screen, which meant scrolling past drip
+ * cadence and two paragraphs of explanation to reach the episodes -- the thing the screen is
+ * for. Peter's call, and the prose went with it: this is a single-user app and he wrote the
+ * requirements.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShowSettingsSheet(
+ show: SubscriptionEntity,
+ settings: ShowSettingsViewModel,
+ onDismiss: () -> Unit,
+) {
+ val busy by settings.busy.collectAsState()
+ val detection by settings.detection.collectAsState()
+
+ // Read once per opening. Nothing can move it while the sheet is up: detection only advances
+ // in a background worker.
+ LaunchedEffect(show.id) { settings.loadDetection(show.id) }
+
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(show.title, style = MaterialTheme.typography.titleMedium)
+
+ SwitchRow(
+ label = "Bedtime",
+ description = "Move this show in or out of bedtime",
+ checked = show.bedtime,
+ detail = if (show.bedtime) "Only listed in bedtime mode" else "In the main library",
+ onCheckedChange = { settings.setBedtime(show.id, it) },
+ )
+
+ HorizontalDivider()
+
+ 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()
+
+ SkipSettings(
+ subscription = show,
+ onChange = { introMs, outroMs -> settings.setSkips(show.id, introMs, outroMs) },
+ onSkipAdChapters = { settings.setSkipAdChapters(show.id, it) },
+ )
+
+ HorizontalDivider()
+
+ DetectionReadout(detection)
+ }
+ }
+}
+
+/**
+ * What the automatic detection has worked out for this show.
+ *
+ * Read-only, and the point of it: until this existed, a show whose intro had been confirmed and
+ * one that had never been looked at were indistinguishable -- both simply played.
+ */
+@Composable
+private fun DetectionReadout(detection: ShowDetection?) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text("Detected skips", style = MaterialTheme.typography.titleSmall)
+ if (detection == null) {
+ Text("Reading…", style = MaterialTheme.typography.bodySmall)
+ return@Column
+ }
+ ReadoutRow("Intro", detection.intro)
+ ReadoutRow("Outro", detection.outro)
+ ReadoutRow("Ad breaks", detection.midrolls)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
index 1e972f9..a84fa40 100644
--- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
@@ -8,7 +8,11 @@ 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.FingerprintKind
import org.terst.listen.data.Graph
+import org.terst.listen.fingerprint.IntroOutroDetector
+import org.terst.listen.fingerprint.MidrollDetector
+import org.terst.listen.fingerprint.SkipSummary
/**
* An archive walk, reported in full.
@@ -109,4 +113,50 @@ class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) {
fun setSkipAdChapters(subscriptionId: String, enabled: Boolean) {
viewModelScope.launch { graph.subscriptions.setSkipAdChapters(subscriptionId, enabled) }
}
+
+ /** Moves a show in or out of the bedtime list. */
+ fun setBedtime(subscriptionId: String, enabled: Boolean) {
+ viewModelScope.launch {
+ graph.subscriptions.setBedtime(subscriptionId, enabled)
+ _banner.value = if (enabled) {
+ Banner("Bedtime — hidden until you switch to bedtime mode")
+ } else {
+ Banner("Back in the main library")
+ }
+ }
+ }
+
+ private val _detection = MutableStateFlow<ShowDetection?>(null)
+
+ /** What Tier 3 has worked out for the show currently on screen. */
+ val detection: StateFlow<ShowDetection?> = _detection.asStateFlow()
+
+ /**
+ * Read on demand rather than observed.
+ *
+ * Detection only ever moves when a background worker runs, which is per download for
+ * intro/outro and nightly for midrolls -- never while this sheet is open. A Flow would buy
+ * nothing and would mean three more queries running for every show screen.
+ */
+ fun loadDetection(subscriptionId: String) {
+ viewModelScope.launch {
+ _detection.value = ShowDetection(
+ intro = SkipSummary.candidateState(
+ graph.fingerprints.get(subscriptionId, FingerprintKind.INTRO),
+ IntroOutroDetector.CONFIRM_THRESHOLD,
+ ),
+ outro = SkipSummary.candidateState(
+ graph.fingerprints.get(subscriptionId, FingerprintKind.OUTRO),
+ IntroOutroDetector.CONFIRM_THRESHOLD,
+ ),
+ midrolls = SkipSummary.midrollState(
+ graph.midrolls.getCandidates(subscriptionId),
+ MidrollDetector.CONFIRM_THRESHOLD,
+ ),
+ )
+ }
+ }
}
+
+/** The three lines of Tier 3 state a show has, already worded. */
+data class ShowDetection(val intro: String, val outro: String, val midrolls: String)
diff --git a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt
index 106e3fd..fdee5d0 100644
--- a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt
+++ b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt
@@ -4,17 +4,12 @@ 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
@@ -22,11 +17,11 @@ import org.terst.listen.data.SubscriptionEntity
private const val MS_PER_SECOND = 1000L
/**
- * Per-show intro and outro offsets.
+ * Per-show intro and outro offsets, and the chapter-break opt-in.
*
* 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.
+ * the override for when Tier 3 gets a show wrong.
*/
@Composable
fun SkipSettings(
@@ -35,7 +30,7 @@ fun SkipSettings(
onSkipAdChapters: (Boolean) -> Unit,
) {
Column(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Skip", style = MaterialTheme.typography.titleSmall)
@@ -51,25 +46,11 @@ fun SkipSettings(
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(
- "On by default. Only acts 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,
+ SwitchRow(
+ label = "Skip marked breaks",
+ description = "Skip marked ad chapters",
+ checked = subscription.skipAdChapters,
+ onCheckedChange = onSkipAdChapters,
)
}
}
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 6018e56..c93f353 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -15,11 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -66,6 +69,7 @@ fun SubscriptionScreen(
nav: SubscriptionNav,
) {
var confirming by remember { mutableStateOf(false) }
+ var showingSettings by remember { mutableStateOf(false) }
val refreshing by vm.refreshing.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId }
@@ -83,9 +87,12 @@ fun SubscriptionScreen(
SubscriptionTopBar(
title = subscription?.title,
refreshing = refreshing,
- onRefresh = { vm.refresh(subscriptionId) },
- onUnsubscribe = { confirming = true },
- onBack = nav.back,
+ actions = SubscriptionTopBarActions(
+ refresh = { vm.refresh(subscriptionId) },
+ openSettings = { showingSettings = true },
+ unsubscribe = { confirming = true },
+ back = nav.back,
+ ),
)
},
snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
@@ -111,10 +118,10 @@ fun SubscriptionScreen(
modifier = Modifier.padding(padding),
) {
PlayedToggle(showPlayed, vm::setShowPlayed)
- subscription?.let { show ->
- HorizontalDivider(Modifier.padding(vertical = 12.dp))
- ShowSettings(show, settings)
- }
+ }
+
+ subscription?.takeIf { showingSettings }?.let { show ->
+ ShowSettingsSheet(show, settings) { showingSettings = false }
}
}
}
@@ -192,50 +199,16 @@ 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) {
- val busy by settings.busy.collectAsState()
-
- 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))
-}
-
/** 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" },
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
+ SwitchRow(
+ label = "Show played episodes",
+ description = "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,
- )
- }
}
}
@@ -295,28 +268,52 @@ data class SubscriptionNav(
val back: () -> Unit,
)
+/** The top bar's four callbacks, bundled so the signature stays readable. */
+private data class SubscriptionTopBarActions(
+ val refresh: () -> Unit,
+ val openSettings: () -> Unit,
+ val unsubscribe: () -> Unit,
+ val back: () -> Unit,
+)
+
+/**
+ * Refresh stays an icon; everything else is behind the overflow.
+ *
+ * Unsubscribe in particular: it was one tap from a bin icon next to Refresh, which is a hard
+ * action to undo sitting next to one you reach for constantly.
+ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SubscriptionTopBar(
title: String?,
refreshing: Boolean,
- onRefresh: () -> Unit,
- onUnsubscribe: () -> Unit,
- onBack: () -> Unit,
+ actions: SubscriptionTopBarActions,
) {
+ var expanded by remember { mutableStateOf(false) }
+
TopAppBar(
title = { Text(title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
- IconButton(onClick = onBack) {
+ IconButton(onClick = actions.back) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
- IconButton(onClick = onRefresh, enabled = !refreshing) {
+ IconButton(onClick = actions.refresh, enabled = !refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh this podcast")
}
- IconButton(onClick = onUnsubscribe) {
- Icon(Icons.Default.Delete, contentDescription = "Unsubscribe")
+ IconButton(onClick = { expanded = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "Podcast menu")
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Settings") },
+ onClick = { expanded = false; actions.openSettings() },
+ )
+ DropdownMenuItem(
+ text = { Text("Unsubscribe") },
+ onClick = { expanded = false; actions.unsubscribe() },
+ )
}
},
)
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
index a085070..6ce26c6 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -84,7 +84,7 @@ class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineW
val graph = Graph.get(applicationContext)
val episode = graph.episodes.getById(episodeId) ?: return Result.success()
- FingerprintUpdater(graph.fingerprints, MediaCodecClipSource()).process(episode)
+ FingerprintUpdater(graph.fingerprints, graph.episodes, MediaCodecClipSource()).process(episode)
return Result.success()
}
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 16f46f4..863f265 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -12,7 +12,13 @@ import kotlinx.coroutines.flow.flowOf
class FakeSubscriptionDao : SubscriptionDao {
val rows = mutableListOf<SubscriptionEntity>()
- override fun observeAll(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList())
+ // Ordering and hasWaiting are both SQL, so neither is reproduced here -- LibraryOrderTest
+ // pins them against a real database instead. This only honours the bedtime split, which
+ // callers do branch on.
+ override fun observeAll(bedtime: Boolean): Flow<List<SubscriptionListing>> =
+ flowOf(rows.filter { it.bedtime == bedtime }.map { SubscriptionListing(it, hasWaiting = true) })
+
+ override fun observeEvery(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList())
override suspend fun getAll() = rows.toList()
override suspend fun getById(id: String) = rows.firstOrNull { it.id == id }
override suspend fun getByFeedUrl(feedUrl: String) = rows.firstOrNull { it.feedUrl == feedUrl }
@@ -48,6 +54,10 @@ class FakeSubscriptionDao : SubscriptionDao {
replace(id) { it.copy(skipAdChapters = enabled) }
}
+ override suspend fun setBedtime(id: String, enabled: Boolean) {
+ replace(id) { it.copy(bedtime = enabled) }
+ }
+
override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) {
replace(id) { it.copy(skipIntroMs = introMs, skipOutroMs = outroMs) }
}
diff --git a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt
index 0cc9d7f..2139215 100644
--- a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt
+++ b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt
@@ -12,12 +12,11 @@ import org.junit.Test
*/
class ShowFingerprintEntityTest {
- private fun entity(pcm: ByteArray?, pending: ByteArray? = null) = ShowFingerprintEntity(
+ private fun entity(pcm: ByteArray?, pendingEpisodeId: String? = null) = ShowFingerprintEntity(
subscriptionId = "s1",
kind = FingerprintKind.INTRO,
referenceClipPcm = pcm,
- pendingClipPcm = pending,
- pendingEpisodeId = null,
+ pendingEpisodeId = pendingEpisodeId,
sampleRate = 8_000,
confirmedCount = 3,
lastConfirmedEpisodeId = "e1",
@@ -43,15 +42,15 @@ class ShowFingerprintEntityTest {
}
@Test
- fun `two rows with both clips null are equal`() {
- assertEquals(entity(null, null), entity(null, null))
+ fun `two rows with no clip at all are equal`() {
+ assertEquals(entity(null), entity(null))
}
@Test
- fun `differing pending clips are not equal even when the reference clip matches`() {
+ fun `differing pending episodes are not equal even when the reference clip matches`() {
assertNotEquals(
- entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(9)),
- entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(8)),
+ entity(byteArrayOf(1, 2, 3), pendingEpisodeId = "e9"),
+ entity(byteArrayOf(1, 2, 3), pendingEpisodeId = "e8"),
)
}
}
diff --git a/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt
new file mode 100644
index 0000000..7992098
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt
@@ -0,0 +1,60 @@
+package org.terst.listen.data
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.fingerprint.FingerprintUpdater
+import org.terst.listen.fingerprint.MediaCodecClipSource
+import org.terst.listen.fingerprint.StoredClip
+
+/**
+ * Guards the bug class that took Tier 3 down: a blob big enough to write but too big to read.
+ *
+ * Android reads every Room row through a CursorWindow, and a row over 2MB throws
+ * `SQLiteBlobTooBigException` on the way *out*. Nothing complains on insert, nothing complains
+ * at build time, and the unit tests were perfectly happy -- it only surfaced on a device, as a
+ * hard crash the moment a show's second episode was fingerprinted.
+ *
+ * Decoded audio is the only thing in this schema anywhere near that size, so these pin the two
+ * clips that get persisted against the limit rather than trusting anyone to remember it.
+ */
+class StoredClipSizeTest {
+
+ private fun encodedBytes(seconds: Int, sampleRate: Int) = seconds * sampleRate * 2
+
+ /** The reference clip is short by design, and is the one fingerprint blob that is kept. */
+ @Test
+ fun aStoredFingerprintReferenceClipFitsInARow() {
+ val bytes = encodedBytes(FingerprintUpdater.DEFAULT_REFERENCE_SECONDS, FingerprintUpdater.DEFAULT_SAMPLE_RATE)
+
+ assertTrue("reference clip is $bytes bytes", StoredClip.fits(bytes))
+ }
+
+ /**
+ * The whole head/tail window emphatically does not fit, which is why only the pending
+ * *episode id* is stored and the audio re-decoded. If this ever starts passing, the window
+ * shrank and the comment explaining that indirection needs revisiting -- it does not mean
+ * the clip may now be stored.
+ */
+ @Test
+ fun awholeHeadWindowDoesNotFitWhichIsWhyItIsNeverStored() {
+ val bytes = encodedBytes(MediaCodecClipSource.CLIP_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE)
+
+ assertFalse("head window is $bytes bytes", StoredClip.fits(bytes))
+ }
+
+ /** A midroll candidate is a matched segment of unbounded length, so it has to be capped. */
+ @Test
+ fun aMidrollCandidateIsCappedBelowTheLimit() {
+ val cap = encodedBytes(StoredClip.MAX_CANDIDATE_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE)
+
+ assertTrue("cap is $cap bytes", StoredClip.fits(cap))
+ }
+
+ /** Half the hard limit, so the rest of the row can never tip an accepted clip over it. */
+ @Test
+ fun theSafeSizeLeavesHeadroomUnderTheHardLimit() {
+ assertTrue(StoredClip.SAFE_BYTES * 2 <= StoredClip.CURSOR_WINDOW_LIMIT_BYTES)
+ assertFalse(StoredClip.fits(StoredClip.SAFE_BYTES + 1))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
index 4881f88..7dd7d1d 100644
--- a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
+++ b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
@@ -25,7 +25,7 @@ class FingerprintUpdaterTest {
episodes = FakeEpisodeDao()
fingerprints = FakeShowFingerprintDao().apply { episodeRows = episodes.rows }
clips = FakeClipSource()
- updater = FingerprintUpdater(fingerprints, clips, referenceLengthSamples = 40)
+ updater = FingerprintUpdater(fingerprints, episodes, clips, referenceLengthSamples = 40)
}
private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } }
diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
new file mode 100644
index 0000000..bf351ae
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
@@ -0,0 +1,209 @@
+package org.terst.listen.fingerprint
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.FingerprintKind
+import org.terst.listen.data.ShowFingerprintEntity
+import org.terst.listen.data.ShowMidrollEntity
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * The words the app uses to say what the skip detection has worked out.
+ *
+ * Worth pinning rather than writing inline in a composable: the whole point of this readout is
+ * that Peter can tell "confirmed and acting" apart from "still gathering evidence", and a
+ * summary that blurred the two would be worse than no readout at all.
+ */
+class SkipSummaryTest {
+
+ private fun fingerprint(
+ confirmedCount: Int,
+ hasReference: Boolean = true,
+ pendingEpisodeId: String? = null,
+ ) = ShowFingerprintEntity(
+ subscriptionId = "s1",
+ kind = FingerprintKind.INTRO,
+ referenceClipPcm = if (hasReference) byteArrayOf(1, 2) else null,
+ pendingEpisodeId = pendingEpisodeId,
+ sampleRate = 8000,
+ confirmedCount = confirmedCount,
+ lastConfirmedEpisodeId = null,
+ updatedAtMs = 0,
+ )
+
+ private fun episodeRow(
+ detectedIntroMs: Long? = null,
+ detectedOutroMs: Long? = null,
+ midrollCheckedAtMs: Long? = null,
+ ) = EpisodeEntity(
+ id = "e1",
+ subscriptionId = "s1",
+ guid = "g1",
+ title = "One",
+ mediaUrl = "https://cdn/1.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ detectedIntroMs = detectedIntroMs,
+ detectedOutroMs = detectedOutroMs,
+ midrollCheckedAtMs = midrollCheckedAtMs,
+ )
+
+ private fun showRow(skipIntroMs: Long = 0, skipOutroMs: Long = 0) = SubscriptionEntity(
+ id = "s1",
+ feedUrl = "https://example.com/feed.xml",
+ title = "A Show",
+ addedAtMs = 0,
+ skipIntroMs = skipIntroMs,
+ skipOutroMs = skipOutroMs,
+ )
+
+ private fun candidate(id: String, confirmedCount: Int) = ShowMidrollEntity(
+ subscriptionId = "s1",
+ id = id,
+ referenceClipPcm = byteArrayOf(1, 2),
+ sampleRate = 8000,
+ confirmedCount = confirmedCount,
+ lastConfirmedEpisodeId = null,
+ updatedAtMs = 0,
+ )
+
+ @Test
+ fun nothingSeenYet() {
+ assertEquals("Nothing found yet", SkipSummary.candidateState(null, threshold = 3))
+ }
+
+ /** One episode in, there is a clip set aside but nothing to compare it against. */
+ @Test
+ fun waitingForASecondEpisode() {
+ assertEquals(
+ "Nothing found yet",
+ SkipSummary.candidateState(fingerprint(0, hasReference = false, pendingEpisodeId = "e1"), 3),
+ )
+ }
+
+ @Test
+ fun gatheringNamesHowFarOffItIs() {
+ assertEquals("Found, 2 of 3 episodes agree", SkipSummary.candidateState(fingerprint(2), 3))
+ }
+
+ /** The distinction that matters: below the threshold nothing is being skipped. */
+ @Test
+ fun confirmedSaysSo() {
+ assertEquals("Confirmed by 3 episodes", SkipSummary.candidateState(fingerprint(3), 3))
+ }
+
+ @Test
+ fun confirmedKeepsCountingPastTheThreshold() {
+ assertEquals("Confirmed by 7 episodes", SkipSummary.candidateState(fingerprint(7), 3))
+ }
+
+ @Test
+ fun noMidrollCandidates() {
+ assertEquals("None found yet", SkipSummary.midrollState(emptyList(), threshold = 3))
+ }
+
+ @Test
+ fun midrollsCountBothStates() {
+ val candidates = listOf(candidate("a", 4), candidate("b", 3), candidate("c", 2))
+ assertEquals("2 confirmed, 1 still gathering", SkipSummary.midrollState(candidates, 3))
+ }
+
+ @Test
+ fun midrollsGatheringOnly() {
+ assertEquals("1 still gathering", SkipSummary.midrollState(listOf(candidate("a", 2)), 3))
+ }
+
+ @Test
+ fun midrollsConfirmedOnly() {
+ assertEquals("1 confirmed", SkipSummary.midrollState(listOf(candidate("a", 3)), 3))
+ }
+
+ /** Detection wins over the manual offset, and the readout has to say which is in force. */
+ @Test
+ fun offsetNamesWhereTheNumberCameFrom() {
+ val detected = SkipSummary.episode(episodeRow(detectedIntroMs = 47_000), showRow(skipIntroMs = 30_000), emptyList())
+ assertEquals("47s, detected", detected.intro)
+
+ val manual = SkipSummary.episode(episodeRow(), showRow(skipIntroMs = 30_000), emptyList())
+ assertEquals("30s, set by hand", manual.intro)
+
+ val neither = SkipSummary.episode(episodeRow(), showRow(), emptyList())
+ assertEquals("None", neither.intro)
+ }
+
+ /** A detected zero is a real answer -- this show has no intro -- not a missing one. */
+ @Test
+ fun aDetectedZeroIsNotTheSameAsNothingDetected() {
+ val skips = SkipSummary.episode(episodeRow(detectedIntroMs = 0), showRow(skipIntroMs = 30_000), emptyList())
+
+ assertEquals("None, detected", skips.intro)
+ }
+
+ /** Earliest first, whatever order the rows come back in. */
+ @Test
+ fun rangesReadAsClockTimes() {
+ val ranges = listOf(
+ EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 750_000, endMs = 825_000),
+ EpisodeMidrollEntity(episodeId = "e1", candidateId = "b", startMs = 61_000, endMs = 91_500),
+ )
+
+ assertEquals(
+ listOf("1:01 - 1:31", "12:30 - 13:45"),
+ SkipSummary.episode(episodeRow(), showRow(), ranges).breaks,
+ )
+ }
+
+ /**
+ * "Not checked yet" and "none found" are different answers.
+ *
+ * Midroll scanning runs nightly while charging, so a freshly downloaded episode has
+ * legitimately not been looked at -- reporting that as "no ad breaks" would read as the
+ * detection having run and found nothing, which is the opposite of true.
+ */
+ @Test
+ fun anUncheckedEpisodeSaysSoRatherThanClaimingNoBreaks() {
+ val skips = SkipSummary.episode(episodeRow(), showRow(), ranges = emptyList())
+
+ assertEquals(listOf("Not checked yet"), skips.breaks)
+ }
+
+ @Test
+ fun aCheckedEpisodeWithNothingInItSaysNoneFound() {
+ val skips = SkipSummary.episode(
+ episodeRow(midrollCheckedAtMs = 1_000),
+ showRow(),
+ ranges = emptyList(),
+ )
+
+ assertEquals(listOf("None found"), skips.breaks)
+ }
+
+ @Test
+ fun episodeReportsTheOffsetsActuallyInForce() {
+ val skips = SkipSummary.episode(
+ episodeRow(detectedIntroMs = 47_000, midrollCheckedAtMs = 1_000),
+ showRow(skipIntroMs = 30_000, skipOutroMs = 20_000),
+ ranges = listOf(
+ EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 61_000, endMs = 91_500)
+ ),
+ )
+
+ assertEquals("47s, detected", skips.intro)
+ assertEquals("20s, set by hand", skips.outro)
+ assertEquals(listOf("1:01 - 1:31"), skips.breaks)
+ }
+
+ @Test
+ fun rangesPastAnHourKeepTheHour() {
+ val ranges = listOf(
+ EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 3_723_000, endMs = 3_780_000)
+ )
+
+ assertEquals(
+ listOf("1:02:03 - 1:03:00"),
+ SkipSummary.episode(episodeRow(), showRow(), ranges).breaks,
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt b/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt
new file mode 100644
index 0000000..e8bc0a3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt
@@ -0,0 +1,48 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * What entering and leaving bedtime mode does to the sleep timer.
+ *
+ * Four cases, and two of them are the reason this is a class rather than an `if` in the view
+ * model: stomping a timer the listener set by hand, and leaving one armed after they have gone
+ * back to ordinary listening.
+ */
+class BedtimePolicyTest {
+
+ @Test
+ fun enteringArmsTheTimer() {
+ assertEquals(
+ BedtimeAction.ArmSleepTimer(BedtimePolicy.DEFAULT_MINUTES),
+ BedtimePolicy.entering(timerAlreadyArmed = false),
+ )
+ }
+
+ /** A timer set by hand was set deliberately; bedtime mode must not overwrite the duration. */
+ @Test
+ fun enteringLeavesATimerAlreadyRunningAlone() {
+ assertEquals(BedtimeAction.Nothing, BedtimePolicy.entering(timerAlreadyArmed = true))
+ }
+
+ @Test
+ fun leavingCancelsTheTimerBedtimeArmed() {
+ assertEquals(BedtimeAction.CancelSleepTimer, BedtimePolicy.leaving(bedtimeArmedIt = true))
+ }
+
+ /**
+ * The inverse of the case above. Leaving bedtime mode must not cancel a timer it never
+ * set -- otherwise flicking the moon icon on and off silently kills a manual sleep timer.
+ */
+ @Test
+ fun leavingLeavesAManualTimerRunning() {
+ assertEquals(BedtimeAction.Nothing, BedtimePolicy.leaving(bedtimeArmedIt = false))
+ }
+
+ /** Long rather than short, deliberately: a timer that stops early is the worse failure. */
+ @Test
+ fun theDefaultOutlastsATypicalSleepStory() {
+ assertEquals(45L, BedtimePolicy.DEFAULT_MINUTES)
+ }
+}